diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f8670f..93917ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file. - [#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. + ## [2.1.3] - 2024-10-25 - [#220](https://github.com/os2display/display-api-service/pull/220) diff --git a/config/api_platform/feed_source.yaml b/config/api_platform/feed_source.yaml index 2a51c034..6375b101 100644 --- a/config/api_platform/feed_source.yaml +++ b/config/api_platform/feed_source.yaml @@ -5,9 +5,8 @@ resources: output: App\Dto\FeedSource provider: App\State\FeedSourceProvider processor: App\State\FeedSourceProcessor - operations: - ApiPlatform\Metadata\Get: &get + ApiPlatform\Metadata\Get: &ref_0 normalizationContext: jsonld_embed_context: true openapiContext: @@ -20,7 +19,7 @@ resources: - schema: type: string format: ulid - pattern: "^[A-Za-z0-9]{26}$" + pattern: '^[A-Za-z0-9]{26}$' name: id in: path required: true @@ -29,10 +28,8 @@ resources: description: OK content: application/ld+json: - examples: + examples: null headers: {} - - # https://api-platform.com/docs/core/controllers/ _api_Feed_get_source_config: class: ApiPlatform\Metadata\Get method: GET @@ -49,13 +46,13 @@ resources: - schema: type: string format: ulid - pattern: "^[A-Za-z0-9]{26}$" + pattern: '^[A-Za-z0-9]{26}$' name: id in: path required: true - schema: type: string - pattern: "^[A-Za-z0-9]*$" + pattern: '^[A-Za-z0-9]*$' name: name in: path required: true @@ -66,17 +63,18 @@ resources: examples: example1: value: - - {key: 'key1', id: 'id1', value: 'value1'} + - key: key1 + id: id1 + value: value1 headers: {} - ApiPlatform\Metadata\GetCollection: filters: - - 'entity.search_filter' - - 'entity.blameable_filter' - - 'entity.order_filter' - - 'created.at.order_filter' - - 'modified.at.order_filter' - - 'feed_source.search_filter' + - entity.search_filter + - entity.blameable_filter + - entity.order_filter + - created.at.order_filter + - modified.at.order_filter + - feed_source.search_filter openapiContext: operationId: get-v2-feed-sources description: Retrieves a collection of FeedSource resources. @@ -99,23 +97,108 @@ resources: description: The number of items per page - schema: type: string - pattern: "^[A-Za-z0-9]*$" + pattern: '^[A-Za-z0-9]*$' name: supportedFeedOutputType in: query + responses: + '200': + description: OK + content: + application/ld+json: + examples: null + headers: {} + ApiPlatform\Metadata\Put: + security: is_granted("ROLE_ADMIN") + openapiContext: + description: Update a Feed Source resource. + summary: Update a Feed Source resource. + operationId: put-v2-feed-source-id + tags: + - FeedSources + parameters: + - schema: + type: string + format: ulid + pattern: '^[A-Za-z0-9]{26}$' + name: id + in: path + required: true + ApiPlatform\Metadata\Delete: + security: is_granted("ROLE_ADMIN") + openapiContext: + description: Delete a Feed Source resource. + summary: Delete a Feed Source resource. + operationId: delete-v2-feed-source-id + tags: + - FeedSources + parameters: + - schema: + type: string + format: ulid + pattern: '^[A-Za-z0-9]{26}$' + name: id + in: path required: true + ApiPlatform\Metadata\Post: + security: is_granted("ROLE_ADMIN") + openapiContext: + operationId: create-v2-feed-source + description: Creates a Feed Source resource. + summary: Creates a Feed Source resource. + tags: + - FeedSources + '_api_/feed_sources/{id}/slides_get': &ref_1 + normalizationContext: + groups: + - 'playlist-slide:read' + class: ApiPlatform\Metadata\GetCollection + method: GET + provider: App\State\FeedSourceSlideProvider + filters: + - entity.search_filter + - entity.blameable_filter + - App\Filter\PublishedFilter + - entity.order_filter + - created.at.order_filter + - modified.at.order_filter + uriTemplate: '/feed-sources/{id}/slides' + openapiContext: + description: Retrieves collection of weighted slide resources (feedsource). + summary: Retrieves collection of weighted slide resources (feedsource). + operationId: get-v2-feed-source-slide-id + tags: + - FeedSources + parameters: + - schema: + type: string + format: ulid + pattern: '^[A-Za-z0-9]{26}$' + name: id + in: path + required: true + - schema: + type: integer + minimum: 0 + format: int32 + default: 1 + in: query + name: page + required: true + - schema: + type: string + default: '10' + in: query + name: itemsPerPage + description: The number of items per page responses: '200': description: OK content: application/ld+json: - examples: + examples: null headers: {} - - # Our DTO must be a resource to get a proper URL - # @see https://stackoverflow.com/a/75705084 - # @see https://github.com/api-platform/core/issues/5451 App\Dto\FeedSource: provider: App\State\FeedSourceProvider - operations: - ApiPlatform\Metadata\Get: *get + ApiPlatform\Metadata\Get: *ref_0 + get_slides: *ref_1 diff --git a/config/services.yaml b/config/services.yaml index 2b381d94..72293eb9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -204,6 +204,11 @@ services: arguments: $collectionExtensions: !tagged_iterator api_platform.doctrine.orm.query_extension.collection + App\State\FeedSourceSlideProvider: + tags: [ { name: 'api_platform.state_provider', priority: 2 } ] + arguments: + $collectionExtensions: !tagged_iterator api_platform.doctrine.orm.query_extension.collection + App\State\FeedProvider: tags: [ { name: 'api_platform.state_provider', priority: 2 } ] arguments: diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 3a079edf..ef8d120f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -386,17 +386,6 @@ - - - - - - - - - - - getId()]]> diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index f35f1980..8f67e25d 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -211,11 +211,481 @@ "name": "supportedFeedOutputType", "in": "query", "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "title", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "description", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "createdBy", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "createdBy[]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "allowReserved": false + }, + { + "name": "modifiedBy", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "modifiedBy[]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "allowReserved": false + }, + { + "name": "order[title]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "order[description]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "order[createdAt]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "order[modifiedAt]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ] + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "supportedFeedOutputType[]", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "allowReserved": false + } + ], + "deprecated": false + }, + "post": { + "operationId": "create-v2-feed-source", + "tags": [ + "FeedSources" + ], + "responses": { + "201": { + "description": "FeedSource resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSource.jsonld" + } + }, + "text/html": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSource" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSource" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Unprocessable entity" + } + }, + "summary": "Creates a Feed Source resource.", + "description": "Creates a Feed Source resource.", + "parameters": [], + "requestBody": { + "description": "The new FeedSource resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSourceInput.jsonld" + } + }, + "text/html": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSourceInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSourceInput" + } + } + }, + "required": true + }, + "deprecated": false + } + }, + "/v2/feed-sources/{id}": { + "get": { + "operationId": "get-feed-source-id", + "tags": [ + "FeedSources" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/ld+json": { + "examples": null + } + }, + "headers": [] + } + }, + "summary": "Retrieve a Feed Source resource.", + "description": "Retrieves a Feed Source resource.", + "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 + } + ], + "deprecated": false + }, + "put": { + "operationId": "put-v2-feed-source-id", + "tags": [ + "FeedSources" + ], + "responses": { + "200": { + "description": "FeedSource resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSource.jsonld" + } + }, + "text/html": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSource" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSource" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Unprocessable entity" + }, + "404": { + "description": "Resource not found" + } + }, + "summary": "Update a Feed Source resource.", + "description": "Update a Feed Source resource.", + "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 + } + ], + "requestBody": { + "description": "The updated FeedSource resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSourceInput.jsonld" + } + }, + "text/html": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSourceInput" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FeedSource.FeedSourceInput" + } + } + }, + "required": true + }, + "deprecated": false + }, + "delete": { + "operationId": "delete-v2-feed-source-id", + "tags": [ + "FeedSources" + ], + "responses": { + "204": { + "description": "FeedSource resource deleted" + }, + "404": { + "description": "Resource not found" + } + }, + "summary": "Delete a Feed Source resource.", + "description": "Delete a Feed Source resource.", + "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 + } + ], + "deprecated": false + } + }, + "/v2/feed-sources/{id}/slides": { + "get": { + "operationId": "get-v2-feed-source-slide-id", + "tags": [ + "FeedSources" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/ld+json": { + "examples": null + } + }, + "headers": [] + } + }, + "summary": "Retrieves collection of weighted slide resources (feedsource).", + "description": "Retrieves collection of weighted slide resources (feedsource).", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "", "required": true, "deprecated": false, "allowEmptyValue": false, "schema": { - "type": "string" + "type": "string", + "format": "ulid", + "pattern": "^[A-Za-z0-9]{26}$" + }, + "style": "simple", + "explode": false, + "allowReserved": false + }, + { + "name": "page", + "in": "query", + "description": "", + "required": true, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "integer", + "minimum": 0, + "format": "int32", + "default": 1 + }, + "style": "form", + "explode": false, + "allowReserved": false + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "allowEmptyValue": false, + "schema": { + "type": "string", + "default": "10" }, "style": "form", "explode": false, @@ -312,26 +782,21 @@ "allowReserved": false }, { - "name": "order[title]", + "name": "published", "in": "query", - "description": "", + "description": "If true only published content will be shown", "required": false, "deprecated": false, "allowEmptyValue": false, "schema": { - "type": "string", - "default": "asc", - "enum": [ - "asc", - "desc" - ] + "type": "boolean" }, "style": "form", "explode": false, "allowReserved": false }, { - "name": "order[description]", + "name": "order[title]", "in": "query", "description": "", "required": false, @@ -350,7 +815,7 @@ "allowReserved": false }, { - "name": "order[createdAt]", + "name": "order[description]", "in": "query", "description": "", "required": false, @@ -369,7 +834,7 @@ "allowReserved": false }, { - "name": "order[modifiedAt]", + "name": "order[createdAt]", "in": "query", "description": "", "required": false, @@ -388,59 +853,21 @@ "allowReserved": false }, { - "name": "supportedFeedOutputType[]", + "name": "order[modifiedAt]", "in": "query", "description": "", "required": false, "deprecated": false, "allowEmptyValue": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "style": "form", - "explode": true, - "allowReserved": false - } - ], - "deprecated": false - } - }, - "/v2/feed-sources/{id}": { - "get": { - "operationId": "get-feed-source-id", - "tags": [ - "FeedSources" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/ld+json": { - "examples": null - } - }, - "headers": [] - } - }, - "summary": "Retrieve a Feed Source resource.", - "description": "Retrieves a Feed Source resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "", - "required": true, - "deprecated": false, - "allowEmptyValue": false, "schema": { "type": "string", - "format": "ulid", - "pattern": "^[A-Za-z0-9]{26}$" + "default": "asc", + "enum": [ + "asc", + "desc" + ] }, - "style": "simple", + "style": "form", "explode": false, "allowReserved": false } @@ -7758,6 +8185,11 @@ } } }, + "FeedSource-playlist-slide.read": { + "type": "object", + "description": "", + "deprecated": false + }, "FeedSource.FeedSource": { "type": "object", "description": "", @@ -7816,19 +8248,16 @@ } } }, + "FeedSource.FeedSource-playlist-slide.read": { + "type": "object", + "description": "", + "deprecated": false + }, "FeedSource.FeedSource.jsonld": { "type": "object", "description": "", "deprecated": false, "properties": { - "@id": { - "readOnly": true, - "type": "string" - }, - "@type": { - "readOnly": true, - "type": "string" - }, "@context": { "readOnly": true, "oneOf": [ @@ -7856,6 +8285,14 @@ } ] }, + "@id": { + "readOnly": true, + "type": "string" + }, + "@type": { + "readOnly": true, + "type": "string" + }, "title": { "type": "string" }, @@ -7909,6 +8346,89 @@ } } }, + "FeedSource.FeedSource.jsonld-playlist-slide.read": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string" + }, + "@type": { + "readOnly": true, + "type": "string" + } + } + }, + "FeedSource.FeedSourceInput": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "outputType": { + "type": "string" + }, + "feedType": { + "type": "string" + }, + "secrets": { + "type": "array", + "items": { + "type": "string" + } + }, + "feeds": { + "type": "array", + "items": { + "type": "string" + } + }, + "supportedFeedOutputType": { + "type": "string" + } + } + }, + "FeedSource.FeedSourceInput.jsonld": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "outputType": { + "type": "string" + }, + "feedType": { + "type": "string" + }, + "secrets": { + "type": "array", + "items": { + "type": "string" + } + }, + "feeds": { + "type": "array", + "items": { + "type": "string" + } + }, + "supportedFeedOutputType": { + "type": "string" + } + } + }, "FeedSource.jsonld": { "type": "object", "description": "", @@ -8002,6 +8522,21 @@ } } }, + "FeedSource.jsonld-playlist-slide.read": { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "@id": { + "readOnly": true, + "type": "string" + }, + "@type": { + "readOnly": true, + "type": "string" + } + } + }, "Media": { "type": "object", "description": "", diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 88df1d6a..5eeaf4a5 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -149,7 +149,7 @@ paths: name: supportedFeedOutputType in: query description: '' - required: true + required: false deprecated: false allowEmptyValue: false schema: @@ -312,6 +312,45 @@ paths: explode: true allowReserved: false deprecated: false + post: + operationId: create-v2-feed-source + tags: + - FeedSources + responses: + '201': + description: 'FeedSource resource created' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/FeedSource.FeedSource.jsonld' + text/html: + schema: + $ref: '#/components/schemas/FeedSource.FeedSource' + multipart/form-data: + schema: + $ref: '#/components/schemas/FeedSource.FeedSource' + links: { } + '400': + description: 'Invalid input' + '422': + description: 'Unprocessable entity' + summary: 'Creates a Feed Source resource.' + description: 'Creates a Feed Source resource.' + parameters: [] + requestBody: + description: 'The new FeedSource resource' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/FeedSource.FeedSourceInput.jsonld' + text/html: + schema: + $ref: '#/components/schemas/FeedSource.FeedSourceInput' + multipart/form-data: + schema: + $ref: '#/components/schemas/FeedSource.FeedSourceInput' + required: true + deprecated: false '/v2/feed-sources/{id}': get: operationId: get-feed-source-id @@ -342,6 +381,298 @@ paths: explode: false allowReserved: false deprecated: false + put: + operationId: put-v2-feed-source-id + tags: + - FeedSources + responses: + '200': + description: 'FeedSource resource updated' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/FeedSource.FeedSource.jsonld' + text/html: + schema: + $ref: '#/components/schemas/FeedSource.FeedSource' + multipart/form-data: + schema: + $ref: '#/components/schemas/FeedSource.FeedSource' + links: { } + '400': + description: 'Invalid input' + '422': + description: 'Unprocessable entity' + '404': + description: 'Resource not found' + summary: 'Update a Feed Source resource.' + description: 'Update a Feed Source resource.' + 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 + requestBody: + description: 'The updated FeedSource resource' + content: + application/ld+json: + schema: + $ref: '#/components/schemas/FeedSource.FeedSourceInput.jsonld' + text/html: + schema: + $ref: '#/components/schemas/FeedSource.FeedSourceInput' + multipart/form-data: + schema: + $ref: '#/components/schemas/FeedSource.FeedSourceInput' + required: true + deprecated: false + delete: + operationId: delete-v2-feed-source-id + tags: + - FeedSources + responses: + '204': + description: 'FeedSource resource deleted' + '404': + description: 'Resource not found' + summary: 'Delete a Feed Source resource.' + description: 'Delete a Feed Source resource.' + 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 + deprecated: false + '/v2/feed-sources/{id}/slides': + get: + operationId: get-v2-feed-source-slide-id + tags: + - FeedSources + responses: + '200': + description: OK + content: + application/ld+json: + examples: null + headers: [] + summary: 'Retrieves collection of weighted slide resources (feedsource).' + description: 'Retrieves collection of weighted slide resources (feedsource).' + 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: page + in: query + description: '' + required: true + deprecated: false + allowEmptyValue: false + schema: + type: integer + minimum: 0 + format: int32 + default: 1 + style: form + explode: false + allowReserved: false + - + name: itemsPerPage + in: query + description: 'The number of items per page' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + default: '10' + style: form + explode: false + allowReserved: false + - + name: title + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: description + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: createdBy + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: 'createdBy[]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: array + items: + type: string + style: form + explode: true + allowReserved: false + - + name: modifiedBy + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + style: form + explode: false + allowReserved: false + - + name: 'modifiedBy[]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: array + items: + type: string + style: form + explode: true + allowReserved: false + - + name: published + in: query + description: 'If true only published content will be shown' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: boolean + style: form + explode: false + allowReserved: false + - + name: 'order[title]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + default: asc + enum: + - asc + - desc + style: form + explode: false + allowReserved: false + - + name: 'order[description]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + default: asc + enum: + - asc + - desc + style: form + explode: false + allowReserved: false + - + name: 'order[createdAt]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + default: asc + enum: + - asc + - desc + style: form + explode: false + allowReserved: false + - + name: 'order[modifiedAt]' + in: query + description: '' + required: false + deprecated: false + allowEmptyValue: false + schema: + type: string + default: asc + enum: + - asc + - desc + style: form + explode: false + allowReserved: false + deprecated: false '/v2/feed_sources/{id}/config/{name}': get: operationId: get-v2-feed-source-id-config-name @@ -5462,6 +5793,10 @@ components: modified: type: string format: date-time + FeedSource-playlist-slide.read: + type: object + description: '' + deprecated: false FeedSource.FeedSource: type: object description: '' @@ -5502,17 +5837,15 @@ components: modified: type: string format: date-time + FeedSource.FeedSource-playlist-slide.read: + type: object + description: '' + deprecated: false FeedSource.FeedSource.jsonld: type: object description: '' deprecated: false properties: - '@id': - readOnly: true - type: string - '@type': - readOnly: true - type: string '@context': readOnly: true oneOf: @@ -5530,6 +5863,12 @@ components: - '@vocab' - hydra additionalProperties: true + '@id': + readOnly: true + type: string + '@type': + readOnly: true + type: string title: type: string description: @@ -5565,6 +5904,63 @@ components: modified: type: string format: date-time + FeedSource.FeedSource.jsonld-playlist-slide.read: + type: object + description: '' + deprecated: false + properties: + '@id': + readOnly: true + type: string + '@type': + readOnly: true + type: string + FeedSource.FeedSourceInput: + type: object + description: '' + deprecated: false + properties: + title: + type: string + description: + type: string + outputType: + type: string + feedType: + type: string + secrets: + type: array + items: + type: string + feeds: + type: array + items: + type: string + supportedFeedOutputType: + type: string + FeedSource.FeedSourceInput.jsonld: + type: object + description: '' + deprecated: false + properties: + title: + type: string + description: + type: string + outputType: + type: string + feedType: + type: string + secrets: + type: array + items: + type: string + feeds: + type: array + items: + type: string + supportedFeedOutputType: + type: string FeedSource.jsonld: type: object description: '' @@ -5628,6 +6024,17 @@ components: modified: type: string format: date-time + FeedSource.jsonld-playlist-slide.read: + type: object + description: '' + deprecated: false + properties: + '@id': + readOnly: true + type: string + '@type': + readOnly: true + type: string Media: type: object description: '' diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php index f4dd4372..2c456595 100644 --- a/src/Entity/Tenant/FeedSource.php +++ b/src/Entity/Tenant/FeedSource.php @@ -105,4 +105,41 @@ public function setSupportedFeedOutputType(string $supportedFeedOutputType): sel return $this; } + + /** + * Retrieves the JSON schema for validation. + * + * @return array The JSON schema definition + */ + public function getSchema(): array + { + return [ + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + '$id' => 'https://os2display.dk/config-schema.json', + 'title' => 'Config file schema', + 'description' => 'Schema for defining config files for templates', + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'description' => 'The title of the feed source', + 'type' => 'string', + 'minLength' => 1, + ], + 'description' => [ + 'description' => 'A description of the feed source', + 'type' => 'string', + 'minLength' => 1, + ], + 'feedType' => [ + 'description' => 'The type of the feed source', + 'type' => 'string', + 'minLength' => 1, + ], + 'secrets' => [ + 'type' => 'array', + ], + ], + 'required' => ['title', 'description', 'feedType', 'secrets'], + ]; + } } diff --git a/src/Feed/EventDatabaseApiFeedType.php b/src/Feed/EventDatabaseApiFeedType.php index 77545187..e2d5416c 100644 --- a/src/Feed/EventDatabaseApiFeedType.php +++ b/src/Feed/EventDatabaseApiFeedType.php @@ -304,4 +304,23 @@ public function getSupportedFeedOutputType(): string { return self::SUPPORTED_FEED_TYPE; } + + /** + * @throws \JsonException + */ + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => [ + 'host' => [ + 'type' => 'string', + 'format' => 'url', + 'pattern' => 'https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-zA-Z0-9()]{2,6}\\b([-a-zA-Z0-9()@:%_\\+~#?&//=]*)', + ], + ], + 'required' => ['host'], + ]; + } } diff --git a/src/Feed/FeedTypeInterface.php b/src/Feed/FeedTypeInterface.php index 2eda1837..93188eab 100644 --- a/src/Feed/FeedTypeInterface.php +++ b/src/Feed/FeedTypeInterface.php @@ -60,4 +60,11 @@ public function getRequiredConfiguration(): array; * @return string */ public function getSupportedFeedOutputType(): string; + + /** + * Get validation scheme for feed type. + * + * @return mixed + */ + public function getSchema(): mixed; } diff --git a/src/Feed/KobaFeedType.php b/src/Feed/KobaFeedType.php index d18b26b2..1d37cf42 100644 --- a/src/Feed/KobaFeedType.php +++ b/src/Feed/KobaFeedType.php @@ -257,4 +257,12 @@ private function getBookingsFromResource(string $host, string $apikey, string $r return $response->toArray(); } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + ]; + } } diff --git a/src/Feed/NotifiedFeedType.php b/src/Feed/NotifiedFeedType.php index 5fd3996a..5bf387df 100644 --- a/src/Feed/NotifiedFeedType.php +++ b/src/Feed/NotifiedFeedType.php @@ -247,4 +247,18 @@ private function wrapTags(string $input): string '', ]); } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => [ + 'token' => [ + 'type' => 'string', + ], + ], + 'required' => ['token'], + ]; + } } diff --git a/src/Feed/RssFeedType.php b/src/Feed/RssFeedType.php index ed3bf8e7..7d3de686 100644 --- a/src/Feed/RssFeedType.php +++ b/src/Feed/RssFeedType.php @@ -136,4 +136,12 @@ public function getSupportedFeedOutputType(): string { return self::SUPPORTED_FEED_TYPE; } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + ]; + } } diff --git a/src/Feed/SparkleIOFeedType.php b/src/Feed/SparkleIOFeedType.php index 5e13bc4f..430d1bf5 100644 --- a/src/Feed/SparkleIOFeedType.php +++ b/src/Feed/SparkleIOFeedType.php @@ -284,4 +284,12 @@ private function wrapTags(string $input): string return $text; } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + ]; + } } diff --git a/src/Repository/FeedSourceRepository.php b/src/Repository/FeedSourceRepository.php index 46972d1c..85c2fc8f 100644 --- a/src/Repository/FeedSourceRepository.php +++ b/src/Repository/FeedSourceRepository.php @@ -5,8 +5,12 @@ namespace App\Repository; use App\Entity\Tenant\FeedSource; +use App\Entity\Tenant\Slide; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Uid\Ulid; /** * @method FeedSource|null find($id, $lockMode = null, $lockVersion = null) @@ -16,8 +20,25 @@ */ class FeedSourceRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) - { + public function __construct( + ManagerRegistry $registry, + private readonly EntityManagerInterface $entityManager, + ) { parent::__construct($registry, FeedSource::class); } + + public function getFeedSourceSlideRelationsFromFeedSourceId(Ulid $feedSourceUlid): QueryBuilder + { + $queryBuilder = $this->entityManager->createQueryBuilder(); + + $queryBuilder + ->select('s', 'f', 'fs') + ->from(Slide::class, 's') + ->leftJoin('s.feed', 'f') + ->leftJoin('f.feedSource', 'fs') + ->where('fs.id = :feedSourceId') + ->setParameter('feedSourceId', $feedSourceUlid, 'ulid'); + + return $queryBuilder; + } } diff --git a/src/State/FeedSourceProcessor.php b/src/State/FeedSourceProcessor.php index 37e6222d..051a3be0 100644 --- a/src/State/FeedSourceProcessor.php +++ b/src/State/FeedSourceProcessor.php @@ -4,47 +4,156 @@ namespace App\State; +use ApiPlatform\Metadata\DeleteOperationInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; use ApiPlatform\State\ProcessorInterface; use App\Dto\FeedSourceInput; +use App\Entity\Interfaces\TenantScopedUserInterface; use App\Entity\Tenant\FeedSource; +use App\Exceptions\UnknownFeedTypeException; +use App\Repository\FeedSourceRepository; +use App\Service\FeedService; use Doctrine\ORM\EntityManagerInterface; +use JsonSchema\Constraints\Factory; +use JsonSchema\SchemaStorage; +use JsonSchema\Validator; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; -class FeedSourceProcessor implements ProcessorInterface +class FeedSourceProcessor extends AbstractProcessor { public function __construct( - private readonly EntityManagerInterface $entityManager, - ) {} + EntityManagerInterface $entityManager, + ProcessorInterface $persistProcessor, + ProcessorInterface $removeProcessor, + private readonly FeedSourceRepository $feedSourceRepository, + private readonly FeedService $feedService, + private readonly Security $security, + ) { + parent::__construct($entityManager, $persistProcessor, $removeProcessor); + } - /** - * {@inheritdoc} - */ - public function process(mixed $object, Operation $operation, array $uriVariables = [], array $context = []) + public function process($data, Operation $operation, array $uriVariables = [], array $context = []): ?object { - $entity = $this->fromInput($object, $operation, $uriVariables, $context); - $this->entityManager->persist($entity); - $this->entityManager->flush(); + if ($operation instanceof DeleteOperationInterface) { + $queryBuilder = $this->feedSourceRepository->getFeedSourceSlideRelationsFromFeedSourceId($uriVariables['id']); + $hasSlides = $queryBuilder->getQuery()->getResult(); + if ($hasSlides) { + throw new ConflictHttpException('This feed source is used by one or more slides and cannot be deleted.'); + } + } - return $entity; + return parent::process($data, $operation, $uriVariables, $context); } /** - * @return T + * @throws UnknownFeedTypeException + * @throws \JsonException */ - protected function fromInput(FeedSourceInput $object, Operation $operation, array $uriVariables, array $context): FeedSource + protected function fromInput(mixed $object, Operation $operation, array $uriVariables, array $context): FeedSource { - // FIXME Do we really have to do (something like) this to load an existing object into the entity manager? + // Set feed source properties $feedSource = $this->loadPrevious(new FeedSource(), $context); - /* @var FeedSourceInput $object */ - empty($object->title) ?: $feedSource->setTitle($object->title); - empty($object->description) ?: $feedSource->setDescription($object->description); - empty($object->createdBy) ?: $feedSource->setCreatedBy($object->createdBy); - empty($object->modifiedBy) ?: $feedSource->setModifiedBy($object->modifiedBy); - empty($object->secrets) ?: $feedSource->setSecrets($object->secrets); - empty($object->feedType) ?: $feedSource->setFeedType($object->feedType); - empty($object->supportedFeedOutputType) ?: $feedSource->setSupportedFeedOutputType($object->supportedFeedOutputType); + if (!$feedSource instanceof FeedSource) { + throw new InvalidArgumentException('object must by of type FeedSource'); + } + + $this->updateFeedSourceProperties($feedSource, $object); + + // Set tenant + $user = $this->security->getUser(); + if (!$user instanceof TenantScopedUserInterface) { + throw new InvalidArgumentException('The user is not a tenant owner.'); + } + $feedSource->setTenant($user->getActiveTenant()); + + // Validate feed source + $this->validateFeedSource($object, $operation); return $feedSource; } + + protected function updateFeedSourceProperties(FeedSource $feedSource, FeedSourceInput $object): void + { + if (!empty($object->title)) { + $feedSource->setTitle($object->title); + } + if (!empty($object->description)) { + $feedSource->setDescription($object->description); + } + if (!empty($object->createdBy)) { + $feedSource->setCreatedBy($object->createdBy); + } + if (!empty($object->modifiedBy)) { + $feedSource->setModifiedBy($object->modifiedBy); + } + if (!empty($object->secrets)) { + $feedSource->setSecrets($object->secrets); + } + if (!empty($object->feedType)) { + $feedSource->setFeedType($object->feedType); + } + $supportedFeedOutputType = $feedSource->getSupportedFeedOutputType(); + if (null !== $supportedFeedOutputType) { + $feedSource->setSupportedFeedOutputType($supportedFeedOutputType); + } + } + + /** + * @throws \JsonException + * @throws UnknownFeedTypeException + */ + private function validateFeedSource(object $object, Operation $operation): void + { + $validator = $this->prepareValidator(); + + // Prepare base feed source validation schema + $feedSourceValidationSchema = (new FeedSource())->getSchema(); + + // Validate base feed source + $this->executeValidation($object, $validator, $feedSourceValidationSchema); + + // Validate dynamic feed type class + $feedTypeClassName = $object->feedType; + $feedType = $this->feedService->getFeedType($feedTypeClassName); + + $feedTypeValidationSchema = $feedType->getSchema(); + + // If updating and secrets are not set, don't validate. + if ($operation instanceof Put && empty($object->secrets)) { + return; + } + + // Validate secrets based on specific feed type. + $secrets = (object) $object->secrets; + $this->executeValidation($secrets, $validator, $feedTypeValidationSchema); + } + + private function prepareValidator(): Validator + { + $schemaStorage = new SchemaStorage(); + $feedSourceValidationSchema = (object) (new FeedSource())->getSchema(); + $schemaStorage->addSchema('file://contentSchema', $feedSourceValidationSchema); + + return new Validator(new Factory($schemaStorage)); + } + + /** + * @throws \JsonException + */ + private function executeValidation(mixed $object, Validator $validator, ?array $schema = null): void + { + $validator->validate($object, $schema ?? (new FeedSource())->getSchema()); + if (!$validator->isValid()) { + throw new InvalidArgumentException($this->getErrorMessage($validator)); + } + } + + private function getErrorMessage(Validator $validator): string + { + return $validator->getErrors()[0]['property'].' '.$validator->getErrors()[0]['message']; + } } diff --git a/src/State/FeedSourceSlideProvider.php b/src/State/FeedSourceSlideProvider.php new file mode 100644 index 00000000..631a9cdc --- /dev/null +++ b/src/State/FeedSourceSlideProvider.php @@ -0,0 +1,81 @@ +validationUtils->validateUlid($id); + + $queryBuilder = $this->feedSourceRepository->getFeedSourceSlideRelationsFromFeedSourceId($feedSourceUlid); + + foreach ($this->collectionExtensions as $extension) { + if ($extension instanceof QueryCollectionExtensionInterface) { + $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operation, + $context); + } + } + + $request = $this->requestStack->getCurrentRequest(); + $itemsPerPage = $request->query?->get('itemsPerPage') ?? 10; + $page = $request->query?->get('page') ?? 1; + $firstResult = ((int) $page - 1) * (int) $itemsPerPage; + $query = $queryBuilder->getQuery() + ->setFirstResult($firstResult) + ->setMaxResults((int) $itemsPerPage); + + $doctrinePaginator = new DoctrinePaginator($query); + + return new Paginator($doctrinePaginator); + } + + public function toOutput(object $object): SlideDTO + { + assert($object instanceof Slide); + $output = new SlideDTO(); + + $id = $object->getId(); + if (!$id instanceof Ulid) { + throw new \RuntimeException('Can\'t assign id as Slide->getId() did not return a Ulid object.'); + } + + $output->id = $id; + $output->title = $object->getTitle(); + + return $output; + } +} diff --git a/tests/Api/FeedSourceTest.php b/tests/Api/FeedSourceTest.php index f0e1a000..f427db7c 100644 --- a/tests/Api/FeedSourceTest.php +++ b/tests/Api/FeedSourceTest.php @@ -4,8 +4,11 @@ namespace App\Tests\Api; +use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Entity\Tenant\Slide; use App\Tests\AbstractBaseApiTestCase; +use Symfony\Component\HttpClient\Exception\ClientException; class FeedSourceTest extends AbstractBaseApiTestCase { @@ -48,4 +51,200 @@ public function testGetItem(): void '@id' => $iri, ]); } + + public function testCreateFeedSource(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $response = $client->request('POST', '/v2/feed-sources', [ + 'json' => [ + 'title' => 'Test feed source', + 'description' => 'This is a test feed source', + 'feedType' => 'App\Feed\EventDatabaseApiFeedType', + 'secrets' => [ + 'host' => 'https://www.test.dk', + ], + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/FeedSource', + '@type' => 'FeedSource', + 'title' => 'Test feed source', + 'description' => 'This is a test feed source', + 'feedType' => 'App\Feed\EventDatabaseApiFeedType', + 'secrets' => [ + 'host' => 'https://www.test.dk', + ], + 'createdBy' => 'test@example.com', + 'modifiedBy' => 'test@example.com', + ]); + $this->assertMatchesRegularExpression('@^/v\d/[\w-]+/([A-Za-z0-9]{26})$@', $response->toArray()['@id']); + } + + public function testCreateFeedSourceWithoutTitle(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $this->expectException(ClientException::class); + + $response = $client->request('POST', '/v2/feed-sources', [ + 'json' => [ + 'description' => 'This is a test feed source', + 'outputType' => 'This is a test output type', + 'feedType' => 'This is a test feed type', + 'secrets' => [ + 'test secret', + ], + + 'supportedFeedOutputType' => 'Supported feed output type', + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + $this->assertMatchesRegularExpression('@^/v\d/[\w-]+/([A-Za-z0-9]{26})$@', $response->toArray()['@id']); + } + + public function testCreateFeedSourceWithoutDescription(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $this->expectException(ClientException::class); + + $response = $client->request('POST', '/v2/feed-sources', [ + 'json' => [ + 'title' => 'Test feed source', + 'outputType' => 'This is a test output type', + 'feedType' => 'This is a test feed type', + 'secrets' => [ + 'test secret', + ], + + 'supportedFeedOutputType' => 'Supported feed output type', + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertMatchesRegularExpression('@^/v\d/[\w-]+/([A-Za-z0-9]{26})$@', $response->toArray()['@id']); + } + + public function testCreateFeedSourceWithEventDatabaseFeedTypeWithoutRequiredSecret(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $this->expectException(ClientException::class); + + $response = $client->request('POST', '/v2/feed-sources', [ + 'json' => [ + 'title' => 'Test feed source', + 'outputType' => 'This is a test output type', + 'feedType' => 'App\\Feed\\EventDatabaseApiFeedType', + 'secrets' => [ + 'test secret', + ], + + 'supportedFeedOutputType' => 'Supported feed output type', + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertMatchesRegularExpression('@^/v\d/[\w-]+/([A-Za-z0-9]{26})$@', $response->toArray()['@id']); + } + + public function testUpdateFeedSource(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + $iri = $this->findIriBy(FeedSource::class, ['tenant' => $this->tenant, 'title' => 'Test feed source']); + + $client->request('PUT', $iri, [ + 'json' => [ + 'title' => 'Updated title', + 'description' => 'Updated description', + 'outputType' => 'This is a test output type', + 'feedType' => 'App\Feed\EventDatabaseApiFeedType', + 'secrets' => [ + ], + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@type' => 'FeedSource', + '@id' => $iri, + 'title' => 'Updated title', + 'description' => 'Updated description', + ]); + } + + public function testDeleteFeedSource(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $response = $client->request('POST', '/v2/feed-sources', [ + 'json' => [ + 'title' => 'Test feed source', + 'description' => 'This is a test feed source', + 'outputType' => 'This is a test output type', + 'feedType' => 'App\Feed\EventDatabaseApiFeedType', + 'secrets' => [ + 'host' => 'https://www.test.dk', + ], + ], + 'headers' => [ + 'Content-Type' => 'application/ld+json', + ], + ]); + $this->assertResponseIsSuccessful(); + + $iri = $response->toArray()['@id']; + $client->request('DELETE', $iri); + + $this->assertResponseStatusCodeSame(204); + + $ulid = $this->iriHelperUtils->getUlidFromIRI($iri); + $this->assertNull( + static::getContainer()->get('doctrine')->getRepository(FeedSource::class)->findOneBy(['id' => $ulid]) + ); + } + + public function testDeleteFeedSourceInUse(): void + { + $client = $this->getAuthenticatedClient('ROLE_ADMIN'); + + $manager = static::getContainer()->get('doctrine')->getManager(); + + $slide = $manager->getRepository(Slide::class)->findOneBy(['title' => 'slide_abc_1']); + $this->assertInstanceOf(Slide::class, $slide, 'This test requires the slide titled slide_abc_1 with connected feed and feed source'); + + $feed = $slide->getFeed(); + $this->assertInstanceOf(Feed::class, $feed, 'This test requires a slide with a feed connected'); + + $feedSource = $feed->getFeedSource(); + $this->assertInstanceOf(FeedSource::class, $feedSource, 'This test requires a feed with a feed source connected'); + + $feedSourceIri = $this->findIriBy(FeedSource::class, ['id' => $feedSource->getId()]); + $client->request('DELETE', $feedSourceIri); + + // Assert that delete request throws an integrity constraint violation error + $this->assertResponseStatusCodeSame(409); + + $ulid = $this->iriHelperUtils->getUlidFromIRI($feedSourceIri); + + $this->assertNotNull( + $manager->getRepository(FeedSource::class)->findOneBy(['id' => $ulid]) + ); + } }