diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index dde97a6b0..04b087918 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -54,6 +54,36 @@ jobs: php: ['8.2', '8.3', '8.4'] os: ['ubuntu-latest'] + services: + mysql: + image: mariadb + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: icinga_unittest + MYSQL_USER: icinga_unittest + MYSQL_PASSWORD: icinga_unittest + options: >- + --health-cmd "mariadb -s -uroot -proot -e'SHOW DATABASES;' 2> /dev/null | grep icinga_unittest > test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306/tcp + + pgsql: + image: postgres + env: + POSTGRES_USER: icinga_unittest + POSTGRES_PASSWORD: icinga_unittest + POSTGRES_DB: icinga_unittest + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + steps: - name: Checkout code base uses: actions/checkout@v4 @@ -75,7 +105,36 @@ jobs: git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-library.git _libraries/ipl git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-thirdparty.git _libraries/vendor + - name: Checkout Icinga Notifications Daemon + run: | + git clone --depth 1 -b main https://github.com/Icinga/icinga-notifications.git _notifications_daemon + + - name: Initialize Icinga Web + run: | + mysql --host="127.0.0.1" --port="${{ job.services.mysql.ports['3306'] }}" --user="root" --password="root" \ + -e "CREATE DATABASE icingaweb; CREATE USER icingaweb@'%' IDENTIFIED BY 'icingaweb'; GRANT ALL ON icingaweb.* TO icingaweb@'%';" + PGPASSWORD=icinga_unittest psql --host="127.0.0.1" --port="${{ job.services.pgsql.ports['5432'] }}" \ + --username "icinga_unittest" -c "CREATE DATABASE icingaweb;" + - name: PHPUnit env: ICINGAWEB_LIBDIR: _libraries + ICINGAWEB_PATH: _icingaweb2 + ICINGA_NOTIFICATIONS_SCHEMA: _notifications_daemon/schema + MYSQL_TESTDB: icinga_unittest + MYSQL_TESTDB_HOST: 127.0.0.1 + MYSQL_TESTDB_PORT: ${{ job.services.mysql.ports['3306'] }} + MYSQL_TESTDB_USER: icinga_unittest + MYSQL_TESTDB_PASSWORD: icinga_unittest + MYSQL_ICINGAWEBDB: icingaweb + MYSQL_ICINGAWEBDB_PASSWORD: icingaweb + MYSQL_ICINGAWEBDB_USER: icingaweb + PGSQL_TESTDB: icinga_unittest + PGSQL_TESTDB_HOST: 127.0.0.1 + PGSQL_TESTDB_PORT: ${{ job.services.pgsql.ports['5432'] }} + PGSQL_TESTDB_USER: icinga_unittest + PGSQL_TESTDB_PASSWORD: icinga_unittest + PGSQL_ICINGAWEBDB: icingaweb + PGSQL_ICINGAWEBDB_PASSWORD: icinga_unittest + PGSQL_ICINGAWEBDB_USER: icinga_unittest run: phpunit --bootstrap _icingaweb2/test/php/bootstrap.php diff --git a/application/clicommands/OpenapiCommand.php b/application/clicommands/OpenapiCommand.php new file mode 100644 index 000000000..571ef343c --- /dev/null +++ b/application/clicommands/OpenapiCommand.php @@ -0,0 +1,157 @@ + Set the path to the directory to scan for PHP files. +* Default: /library/Notifications/Api/ + * + * --exclude Exclude files matching these strings. Wildcard is `*` + * + * --include Include files matching these strings. Wildcard is `*` + * + * --output Set the path to the output file. + * Default: /doc/api/api-v1-public.json + * + * --api-version Set the API version. + * Default: v1 + * If the output path is set the --api-version option is ignored. + * + * --oad-version Set the OpenAPI version. + * Default: 3.1.0 + */ + public function generateAction(): void + { + $directoryInNotifications = $this->params->get('dir', '/library/Notifications/Api/'); + $exclude = $this->params->get('exclude'); + $include = $this->params->get('include'); + $outputPath = $this->params->get('output'); + $apiVersion = $this->params->get('api-version', 'v1'); + $oadVersion = $this->params->get('oad-version', '3.1.0'); + + $notificationsPath = Icinga::app()->getModuleManager()->getModule('notifications')->getBaseDir(); + $directory = $notificationsPath . $directoryInNotifications; + + $baseDirectory = realpath($directory); + if ($baseDirectory === false || ! is_dir($baseDirectory)) { + throw new RuntimeException("Invalid directory: {$directory}"); + } + + $exclude = isset($exclude) ? array_map('trim', explode(',', $exclude)) : []; + $include = isset($include) ? array_map('trim', explode(',', $include)) : []; + $outputPath = $notificationsPath . ($outputPath ?? '/doc/api/api-' . $apiVersion . '-public.json'); + + $files = $this->collectPhpFiles($baseDirectory, $exclude, $include); + + echo "→ Scanning directory: $baseDirectory\n"; + echo "→ Found " . count($files) . " PHP files\n"; + + $generator = new Generator(new PsrLogger()); + $generator->setVersion($oadVersion); + $generator->getProcessorPipeline()->add(new AddGlobal401Response()); + + try { + $openapi = $generator->generate($files); + + $json = $openapi->toJson( + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE | JSON_PRETTY_PRINT + ); + + $dir = dirname($outputPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($outputPath, $json); + + echo "OpenAPI documentation written to: $outputPath\n"; + } catch (Throwable $e) { + fwrite(STDERR, "Error generating OpenAPI: " . $e->getMessage() . "\n"); + exit(1); + } + } + + /** + * Recursively scan a directory for PHP files. + */ + protected function collectPhpFiles(string $baseDirectory, array $exclude, array $include): array + { + $baseDirectory = rtrim($baseDirectory, '/') . '/'; + if (! is_dir($baseDirectory)) { + throw new RuntimeException("Directory $baseDirectory does not exist"); + } + if (! is_readable($baseDirectory)) { + throw new RuntimeException("Directory $baseDirectory is not readable"); + } + + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($baseDirectory, FilesystemIterator::SKIP_DOTS) + ); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = $file->getPathname(); + + if ($exclude !== [] && $this->matchesAnyPattern($path, $exclude)) { + continue; + } + + if ($include !== [] && ! $this->matchesAnyPattern($path, $include)) { + continue; + } + + $files[] = $path; + } + + if (empty($files)) { + throw new RuntimeException("No PHP files found in $baseDirectory"); + } + + return $files; + } + + protected function matchesAnyPattern(string $string, array $patterns): bool + { + foreach ($patterns as $pattern) { + // Escape regex special chars except for '*' + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + if (preg_match($regex, $string)) { + return true; + } + } + + return false; + } +} diff --git a/application/controllers/ApiController.php b/application/controllers/ApiController.php new file mode 100644 index 000000000..fe60d0552 --- /dev/null +++ b/application/controllers/ApiController.php @@ -0,0 +1,76 @@ +assertPermission('notifications/api'); + + $pipeline = new MiddlewarePipeline([ + new ErrorHandlingMiddleware(), + new LegacyRequestConversionMiddleware($this->getRequest()), + new RoutingMiddleware(), + new DispatchMiddleware(), + new ValidationMiddleware(), + new EndpointExecutionMiddleware(), + ]); + + $this->emitResponse($pipeline->execute()); + + exit; + } + + /** + * Emit the HTTP response to the client. + * + * @param ResponseInterface $response The response object to emit. + * + * @return void + */ + protected function emitResponse(ResponseInterface $response): void + { + do { + ob_end_clean(); + } while (ob_get_level() > 0); + + http_response_code($response->getStatusCode()); + + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header(sprintf('%s: %s', $name, $value), false); + } + } + header('Content-Type: application/json'); + + $body = $response->getBody(); + while (! $body->eof()) { + echo $body->read(8192); + } + } +} diff --git a/application/forms/ChannelForm.php b/application/forms/ChannelForm.php index 2ea44de7f..603d2910c 100644 --- a/application/forms/ChannelForm.php +++ b/application/forms/ChannelForm.php @@ -25,6 +25,7 @@ use ipl\Validator\EmailAddressValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use Ramsey\Uuid\Uuid; /** * @phpstan-type ChannelOptionConfig array{ @@ -214,6 +215,7 @@ public function addChannel(): void $channel = $this->getValues(); $channel['config'] = json_encode($this->filterConfig($channel['config']), JSON_FORCE_OBJECT); $channel['changed_at'] = (int) (new DateTime())->format("Uv"); + $channel['external_uuid'] = Uuid::uuid4()->toString(); $this->db->transaction(function (Connection $db) use ($channel): void { $db->insert('channel', $channel); diff --git a/application/forms/ContactGroupForm.php b/application/forms/ContactGroupForm.php index 5a01a549c..dfdb6fa26 100644 --- a/application/forms/ContactGroupForm.php +++ b/application/forms/ContactGroupForm.php @@ -23,6 +23,7 @@ use ipl\Web\FormDecorator\IcingaFormDecorator; use ipl\Web\FormElement\TermInput; use ipl\Web\FormElement\TermInput\Term; +use Ramsey\Uuid\Uuid; class ContactGroupForm extends CompatForm { @@ -187,7 +188,15 @@ public function addGroup(): int $this->db->beginTransaction(); $changedAt = (int) (new DateTime())->format("Uv"); - $this->db->insert('contactgroup', ['name' => trim($data['group_name']), 'changed_at' => $changedAt]); + + $this->db->insert( + 'contactgroup', + [ + 'name' => trim($data['group_name']), + 'changed_at' => $changedAt, + 'external_uuid' => Uuid::uuid4()->toString() + ] + ); $groupIdentifier = $this->db->lastInsertId(); diff --git a/configuration.php b/configuration.php index 5d098aeb2..439e09da3 100644 --- a/configuration.php +++ b/configuration.php @@ -42,6 +42,11 @@ $this->translate('Allow to configure contact groups') ); +$this->providePermission( + 'notifications/api', + $this->translate('Allow to modify configuration via API') +); + $this->provideRestriction( 'notifications/filter/objects', $this->translate('Restrict access to the objects that match the filter') diff --git a/doc/20-REST-API.md b/doc/20-REST-API.md new file mode 100644 index 000000000..c273225fe --- /dev/null +++ b/doc/20-REST-API.md @@ -0,0 +1,27 @@ +# REST API + +Icinga Notifications Web provides a REST API that allows you to manage notification-related resources programmatically. + +With this API, you can: +- Manage **contacts** and **contact groups** +- Read available **notification channels** + +This API enables easy integration with external tools, automation workflows, and configuration management systems. + +## API Versioning + +The API follows a **versioned** structure to ensure backward compatibility and predictable upgrades. + +The current and first stable version is: /icingaweb2/notifications/api/v1 + +Future versions will be accessible under corresponding paths (for example, `/api/v2`), allowing you to migrate at your own pace. + +## API Description + +The complete API reference for version `v1` is available in [`api/v1.md`](api/v1.md). + +It contains an OpenAPI v3.1 description with detailed information about all endpoints, including: +- Request and response schemas +- Example payloads +- Authentication requirements +- Error handling diff --git a/doc/api/api-v1-public.json b/doc/api/api-v1-public.json new file mode 100644 index 000000000..2f5b62eae --- /dev/null +++ b/doc/api/api-v1-public.json @@ -0,0 +1,2343 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Icinga Notifications API", + "description": "API for managing notification Channels, Contacts, and Contact Groups in Icinga.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/icingaweb2/notifications/api/v1", + "description": "Local server" + } + ], + "paths": { + "/channels/{identifier}": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get a specific Channel by its UUID", + "description": "Retrieve detailed information about a specific notification Channel using its UUID", + "operationId": "getChannel", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Channel to retrieve", + "required": true, + "schema": { + "$ref": "#/components/schemas/ChannelUUID" + } + } + ], + "responses": { + "200": { + "description": "Successful response with a single Channel result", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Channel", + "description": "Successfull response with the Channel object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + }, + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "404": { + "description": "Channel Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Channel not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/channels": { + "get": { + "tags": [ + "Channels" + ], + "summary": "List all notification channels or filter by parameters", + "description": "List all notification channels or filter by parameters", + "operationId": "listChannel", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by channel UUID", + "required": false, + "schema": { + "schema": "ChannelUUID", + "title": "ChannelUUID", + "description": "An UUID representing a notification Channel", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": null + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by channel name (supports partial matches)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "Filter by channel type", + "required": false, + "schema": { + "$ref": "#/components/schemas/ChannelTypes" + } + } + ], + "responses": { + "200": { + "description": "Successful response with multiple Channel results", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Successful response with an array of Channel objects", + "type": "array", + "items": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "InvalidFilterParameter": { + "$ref": "#/components/examples/InvalidFilterParameter" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contact-groups/{identifier}": { + "get": { + "tags": [ + "Contact Groups" + ], + "summary": "Get a specific Contact Group by its UUID", + "description": "Retrieve detailed information about a specific notification Contact Group using its UUID", + "operationId": "getContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact Group to retrieve", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "responses": { + "200": { + "description": "Successful response with a single Contactgroup result", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Contactgroup", + "description": "Successfull response with the Contactgroup object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + }, + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "put": { + "tags": [ + "Contact Groups" + ], + "summary": "Update a Contact Group by UUID", + "description": "Update a Contact Group by UUID, if it doesn't exist, it will be created. \\\n The identifier must be the same as the payload id", + "operationId": "updateContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact Group to update", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contactgroup" + } + } + } + }, + "responses": { + "201": { + "description": "Contactgroup created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contactgroup", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contactgroup", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contactgroups/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactgroupCreated": { + "summary": "Contactgroup created successfully", + "value": { + "message": "Contactgroup created successfully" + } + } + } + } + }, + "links": { + "GetContactgroupByIdentifiere": { + "operationId": "getContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactgroupByIdentifier": { + "operationId": "updateContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactgroupByIdentifier": { + "operationId": "deleteContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "204": { + "description": "Contactgroup updated successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contactgroup AlreadyExists": { + "summary": "Contactgroup already exists", + "value": { + "message": "Contactgroup already exists" + } + }, + "InvalidRequestBodyFieldFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFieldFormat" + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "IdentifierMismatch": { + "$ref": "#/components/examples/IdentifierMismatch" + }, + "MissingRequiredRequestBodyField": { + "$ref": "#/components/examples/MissingRequiredRequestBodyField" + }, + "InvalidUserFormat": { + "$ref": "#/components/examples/InvalidUserFormat" + }, + "InvalidUserUUID": { + "$ref": "#/components/examples/InvalidUserUUID" + }, + "NameAlreadyExists": { + "$ref": "#/components/examples/NameAlreadyExists" + }, + "UserNotExists": { + "$ref": "#/components/examples/UserNotExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contact Groups" + ], + "summary": "Replace a Contact Group by UUID", + "description": "Replace a Contact Group by UUID, the identifier must be different from the payload id", + "operationId": "replaceContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact Group to create", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contactgroup" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactgroupUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contactgroup created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contactgroup", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contactgroup", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contactgroups/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactgroupCreated": { + "summary": "Contactgroup created successfully", + "value": { + "message": "Contactgroup created successfully" + } + } + } + } + }, + "links": { + "GetContactgroupByIdentifier": { + "operationId": "getContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactgroupByIdentifier": { + "operationId": "updateContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactgroupByIdentifier": { + "operationId": "deleteContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contactgroup AlreadyExists": { + "summary": "Contactgroup already exists", + "value": { + "message": "Contactgroup already exists" + } + }, + "InvalidRequestBodyFieldFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFieldFormat" + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "$ref": "#/components/examples/MissingRequiredRequestBodyField" + }, + "InvalidUserFormat": { + "$ref": "#/components/examples/InvalidUserFormat" + }, + "InvalidUserUUID": { + "$ref": "#/components/examples/InvalidUserUUID" + }, + "NameAlreadyExists": { + "$ref": "#/components/examples/NameAlreadyExists" + }, + "UserNotExists": { + "$ref": "#/components/examples/UserNotExists" + }, + "IdentifierPayloadIdMissmatch": { + "$ref": "#/components/examples/IdentifierPayloadIdMissmatch" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "delete": { + "tags": [ + "Contact Groups" + ], + "summary": "Delete a Contact Group by UUID", + "description": "Delete a Contact Group by UUID", + "operationId": "deleteContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contactgroup to delete", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "responses": { + "204": { + "description": "No Content - The Contactgroup has been deleted successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contact-groups": { + "get": { + "tags": [ + "Contact Groups" + ], + "summary": "List all Contact Groups or filter by parameters", + "description": "Retrieve all Contact Groups or filter them by parameters.", + "operationId": "listContactgroup", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by Contact Group UUID", + "required": false, + "schema": { + "schema": "ContactgroupUUID", + "title": "ContactgroupUUID", + "description": "An UUID representing a notification Contactgroup", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": null + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by Contact Group name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response with multiple Contactgroup results", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Successful response with an array of Contactgroup objects", + "type": "array", + "items": { + "$ref": "#/components/schemas/Contactgroup" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "InvalidFilterParameter": { + "$ref": "#/components/examples/InvalidFilterParameter" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contact Groups" + ], + "summary": "Create a new Contact Group", + "description": "Create a new Contact Group", + "operationId": "createContactgroup", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contactgroup" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactgroupUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contactgroup created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contactgroup", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contactgroup", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contactgroups/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactgroupCreated": { + "summary": "Contactgroup created successfully", + "value": { + "message": "Contactgroup created successfully" + } + } + } + } + }, + "links": { + "GetContactgroupByIdentifier": { + "operationId": "getContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactgroupByIdentifier": { + "operationId": "updateContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactgroupByIdentifier": { + "operationId": "deleteContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contactgroup AlreadyExists": { + "summary": "Contactgroup already exists", + "value": { + "message": "Contactgroup already exists" + } + }, + "InvalidRequestBodyFieldFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFieldFormat" + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "$ref": "#/components/examples/MissingRequiredRequestBodyField" + }, + "InvalidUserFormat": { + "$ref": "#/components/examples/InvalidUserFormat" + }, + "InvalidUserUUID": { + "$ref": "#/components/examples/InvalidUserUUID" + }, + "NameAlreadyExists": { + "$ref": "#/components/examples/NameAlreadyExists" + }, + "UserNotExists": { + "$ref": "#/components/examples/UserNotExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contacts/{identifier}": { + "get": { + "tags": [ + "Contacts" + ], + "summary": "Get a specific Contact by its UUID", + "description": "Retrieve detailed information about a specific notification Contact using its UUID", + "operationId": "getContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact to retrieve", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "responses": { + "200": { + "description": "Successful response with a single Contact result", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Contact", + "description": "Successfull response with the Contact object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + }, + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "put": { + "tags": [ + "Contacts" + ], + "summary": "Update a Contact by UUID", + "description": "Update a Contact by UUID, if it doesn't exist, it will be created. \\\n The identifier must be the same as the payload id", + "operationId": "updateContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact to Update", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contact" + } + } + } + }, + "responses": { + "201": { + "description": "Contact created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contact", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contact", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contacts/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactCreated": { + "summary": "Contact created successfully", + "value": { + "message": "Contact created successfully" + } + } + } + } + }, + "links": { + "GetContactByIdentifiere": { + "operationId": "getContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactByIdentifier": { + "operationId": "updateContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactByIdentifier": { + "operationId": "deleteContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "204": { + "description": "Contact updated successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contact AlreadyExists": { + "summary": "Contact already exists", + "value": { + "message": "Contact already exists" + } + }, + "InvalidRequestBodyFieldFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFieldFormat" + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "IdentifierMismatch": { + "$ref": "#/components/examples/IdentifierMismatch" + }, + "MissingRequiredRequestBodyField": { + "$ref": "#/components/examples/MissingRequiredRequestBodyField" + }, + "ContactgroupNotExists": { + "$ref": "#/components/examples/ContactgroupNotExists" + }, + "InvalidAddressFormat": { + "$ref": "#/components/examples/InvalidAddressFormat" + }, + "InvalidAddressType": { + "$ref": "#/components/examples/InvalidAddressType" + }, + "InvalidContactgroupUUID": { + "$ref": "#/components/examples/InvalidContactgroupUUID" + }, + "InvalidContactgroupUUIDFormat": { + "$ref": "#/components/examples/InvalidContactgroupUUIDFormat" + }, + "InvalidDefaultChannelUUID": { + "$ref": "#/components/examples/InvalidDefaultChannelUUID" + }, + "InvalidEmailAddress": { + "$ref": "#/components/examples/InvalidEmailAddress" + }, + "InvalidEmailAddressFormat": { + "$ref": "#/components/examples/InvalidEmailAddressFormat" + }, + "InvalidGroupsFormat": { + "$ref": "#/components/examples/InvalidGroupsFormat" + }, + "MissingAddress": { + "$ref": "#/components/examples/MissingAddress" + }, + "UsernameAlreadyExists": { + "$ref": "#/components/examples/UsernameAlreadyExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contacts" + ], + "summary": "Replace a Contact by UUID", + "description": "Replace a Contact by UUID, the identifier must be different from the payload id", + "operationId": "replaceContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the contact to create", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contact created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contact", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contact", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contacts/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactCreated": { + "summary": "Contact created successfully", + "value": { + "message": "Contact created successfully" + } + } + } + } + }, + "links": { + "GetContactByIdentifier": { + "operationId": "getContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactByIdentifier": { + "operationId": "updateContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactByIdentifier": { + "operationId": "deleteContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contact AlreadyExists": { + "summary": "Contact already exists", + "value": { + "message": "Contact already exists" + } + }, + "InvalidRequestBodyFieldFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFieldFormat" + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "$ref": "#/components/examples/MissingRequiredRequestBodyField" + }, + "ContactgroupNotExists": { + "$ref": "#/components/examples/ContactgroupNotExists" + }, + "InvalidAddressType": { + "$ref": "#/components/examples/InvalidAddressType" + }, + "InvalidAddressFormat": { + "$ref": "#/components/examples/InvalidAddressFormat" + }, + "InvalidContactgroupUUID": { + "$ref": "#/components/examples/InvalidContactgroupUUID" + }, + "InvalidContactgroupUUIDFormat": { + "$ref": "#/components/examples/InvalidContactgroupUUIDFormat" + }, + "InvalidDefaultChannelUUID": { + "$ref": "#/components/examples/InvalidDefaultChannelUUID" + }, + "InvalidEmailAddress": { + "$ref": "#/components/examples/InvalidEmailAddress" + }, + "InvalidEmailAddressFormat": { + "$ref": "#/components/examples/InvalidEmailAddressFormat" + }, + "InvalidGroupsFormat": { + "$ref": "#/components/examples/InvalidGroupsFormat" + }, + "MissingAddress": { + "$ref": "#/components/examples/MissingAddress" + }, + "UsernameAlreadyExists": { + "$ref": "#/components/examples/UsernameAlreadyExists" + }, + "IdentifierPayloadIdMissmatch": { + "$ref": "#/components/examples/IdentifierPayloadIdMissmatch" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "delete": { + "tags": [ + "Contacts" + ], + "summary": "Delete a Contact by UUID", + "description": "Delete a Contact by UUID", + "operationId": "deleteContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact to delete", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "responses": { + "204": { + "description": "No Content - The Contact has been deleted successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contacts": { + "get": { + "tags": [ + "Contacts" + ], + "summary": "List all Contacts or filter by parameters", + "description": "Retrieve all Contacts or filter them by parameters.", + "operationId": "listContact", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter Contacts by UUID", + "required": false, + "schema": { + "schema": "ContactUUID", + "title": "ContactUUID", + "description": "An UUID representing a notification Contact", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": null + } + }, + { + "name": "full_name", + "in": "query", + "description": "Filter Contacts by full name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "query", + "description": "Filter Contacts by username", + "required": false, + "schema": { + "type": "string", + "maxLength": 254 + } + } + ], + "responses": { + "200": { + "description": "Successful response with multiple Contact results", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Successful response with an array of Contact objects", + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "InvalidFilterParameter": { + "$ref": "#/components/examples/InvalidFilterParameter" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contacts" + ], + "summary": "Create a new Contact", + "description": "Create a new Contact", + "operationId": "createContact", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contact created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contact", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contact", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contacts/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactCreated": { + "summary": "Contact created successfully", + "value": { + "message": "Contact created successfully" + } + } + } + } + }, + "links": { + "GetContactByIdentifier": { + "operationId": "getContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactByIdentifier": { + "operationId": "updateContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactByIdentifier": { + "operationId": "deleteContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contact AlreadyExists": { + "summary": "Contact already exists", + "value": { + "message": "Contact already exists" + } + }, + "InvalidRequestBodyFieldFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFieldFormat" + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "$ref": "#/components/examples/MissingRequiredRequestBodyField" + }, + "ContactgroupNotExists": { + "$ref": "#/components/examples/ContactgroupNotExists" + }, + "InvalidAddressType": { + "$ref": "#/components/examples/InvalidAddressType" + }, + "InvalidAddressFormat": { + "$ref": "#/components/examples/InvalidAddressFormat" + }, + "InvalidContactgroupUUID": { + "$ref": "#/components/examples/InvalidContactgroupUUID" + }, + "InvalidContactgroupUUIDFormat": { + "$ref": "#/components/examples/InvalidContactgroupUUIDFormat" + }, + "InvalidDefaultChannelUUID": { + "$ref": "#/components/examples/InvalidDefaultChannelUUID" + }, + "InvalidEmailAddress": { + "$ref": "#/components/examples/InvalidEmailAddress" + }, + "InvalidEmailAddressFormat": { + "$ref": "#/components/examples/InvalidEmailAddressFormat" + }, + "InvalidGroupsFormat": { + "$ref": "#/components/examples/InvalidGroupsFormat" + }, + "MissingAddress": { + "$ref": "#/components/examples/MissingAddress" + }, + "UsernameAlreadyExists": { + "$ref": "#/components/examples/UsernameAlreadyExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "components": { + "schemas": { + "Channel": { + "description": "A notification channel represents a destination for notifications in Icinga. \\\n Channels can be of different types, such as email, webhook, or Rocket.Chat, \n each with its own configuration requirements. \\\n Channels are used to route notifications to users or external systems based on their type and configuration.", + "required": [ + "id", + "name", + "type", + "config" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ChannelUUID" + }, + "name": { + "description": "The name of the channel", + "type": "string", + "example": "My Webhook Channel" + }, + "type": { + "$ref": "#/components/schemas/ChannelTypes" + }, + "config": { + "description": "The configuration for the channel, varies depending on the channel type", + "type": "object", + "example": { + "url_template": "https://example.com/webhook?token=abc123" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/EmailChannelConfig" + }, + { + "$ref": "#/components/schemas/WebhookChannelConfig" + }, + { + "$ref": "#/components/schemas/RocketChatChannelConfig" + } + ] + } + }, + "type": "object" + }, + "ChannelUUID": { + "title": "ChannelUUID", + "description": "An UUID representing a notification Channel", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "bb4af7bd-f0da-489c-ae31-23f714bde714" + }, + "ChannelTypes": { + "description": "Available notification channel types", + "type": "string", + "enum": [ + "email", + "webhook", + "rocketchat" + ] + }, + "WebhookChannelConfig": { + "title": "Webhook Channel Config", + "description": "The configuration for a webhook notification channel", + "required": [ + "url_template" + ], + "properties": { + "url_template": { + "$ref": "#/components/schemas/Url", + "description": "URL template for the webhook" + } + }, + "type": "object" + }, + "EmailChannelConfig": { + "title": "Email Channel Config", + "description": "The configuration for an email notification channel", + "required": [ + "host", + "port", + "sender_mail", + "encryption" + ], + "properties": { + "host": { + "description": "SMTP host for sending emails", + "type": "string" + }, + "port": { + "$ref": "#/components/schemas/Port", + "description": "SMTP port for sending emails" + }, + "sender_name": { + "description": "Name of the sender for the email channel", + "type": "string" + }, + "sender_mail": { + "$ref": "#/components/schemas/Email", + "description": "Email address of the sender" + }, + "user": { + "description": "Username for SMTP authentication", + "type": "string" + }, + "password": { + "description": "Password for SMTP authentication", + "type": "string" + }, + "encryption": { + "description": "Encryption method for SMTP", + "type": "string", + "enum": [ + "none", + "ssl", + "tls" + ] + } + }, + "type": "object" + }, + "RocketChatChannelConfig": { + "title": "RocketChat Channel Config", + "description": "The configuration for a Rocket.Chat notification channel", + "required": [ + "url", + "user_id", + "token" + ], + "properties": { + "url": { + "$ref": "#/components/schemas/Url", + "description": "URL of the Rocket.Chat server" + }, + "user_id": { + "description": "User ID for Rocket.Chat", + "type": "string" + }, + "token": { + "description": "Authentication token for Rocket.Chat", + "type": "string" + } + }, + "type": "object" + }, + "Contactgroup": { + "description": "A contact group", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ContactgroupUUID" + }, + "name": { + "description": "The name of the Contact Group", + "type": "string", + "example": "My Contact Group" + }, + "users": { + "description": "List of user identifiers (UUIDs) that belong to this Contact Group", + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactUUID" + } + } + }, + "type": "object" + }, + "ContactgroupUUID": { + "title": "ContactgroupUUID", + "description": "An UUID representing a notification Contactgroup", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "81fb569f-5669-4cd6-93bb-9259446b8b23" + }, + "NewContactgroupUUID": { + "title": "NewContactgroupUUID", + "description": "An UUID representing a notification NewContactgroup", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "31fb569f-5669-4cd6-93bb-9259446b8b74" + }, + "Contact": { + "description": "Schema that represents a contact in the Icinga Notifications API", + "required": [ + "id", + "full_name", + "default_channel", + "addresses" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ContactUUID" + }, + "full_name": { + "description": "The full name of the contact", + "type": "string", + "example": "Icinga User" + }, + "username": { + "description": "The username of the contact", + "type": "string", + "maxLength": 254, + "example": "icingauser" + }, + "default_channel": { + "$ref": "#/components/schemas/ChannelUUID", + "description": "The default channel UUID for the contact" + }, + "groups": { + "description": "List of group UUIDs the contact belongs to", + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactgroupUUID", + "description": "Group UUIDs the contact belongs to" + } + }, + "addresses": { + "$ref": "#/components/schemas/Addresses", + "description": "Contact addresses by type" + } + }, + "type": "object", + "additionalProperties": false + }, + "Addresses": { + "description": "Schema that represents a contact's addresses", + "properties": { + "email": { + "description": "User's email address", + "type": "string", + "format": "email" + }, + "rocketchat": { + "description": "Rocket.Chat identifier or URL", + "type": "string", + "example": "rocketchat.example.com" + }, + "webhook": { + "description": "Comma-separated list of webhook URLs or identifiers", + "type": "string", + "example": "https://example.com/webhook" + } + }, + "type": "object", + "additionalProperties": false + }, + "ContactUUID": { + "title": "ContactUUID", + "description": "An UUID representing a notification Contact", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "9e868ad0-e774-465b-8075-c5a07e8f0726" + }, + "NewContactUUID": { + "title": "NewContactUUID", + "description": "An UUID representing a notification NewContact", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "52668ad0-e774-465b-8075-c5a07e8f0726" + }, + "SuccessResponse": { + "description": "Success response format", + "properties": { + "message": { + "description": "Detailed success message", + "type": "string" + } + }, + "type": "object" + }, + "ErrorResponse": { + "description": "Error response format", + "properties": { + "message": { + "description": "Detailed error message", + "type": "string" + } + }, + "type": "object" + }, + "Email": { + "description": "An email address", + "type": "string", + "format": "email", + "maxLength": 320 + }, + "Port": { + "description": "A port number", + "type": "string", + "maxLength": 5, + "minLength": 1 + }, + "Url": { + "description": "A URL used in the API", + "type": "string", + "maxLength": 2048, + "example": "example.com" + } + }, + "examples": { + "InvalidUserFormat": { + "summary": "Invalid user format", + "value": { + "message": "Invalid request body: expects users to be an array" + } + }, + "InvalidUserUUID": { + "summary": "Invalid user UUID", + "value": { + "message": "Invalid request body: the user identifier X is not a valid UUID" + } + }, + "NameAlreadyExists": { + "summary": "Name already exists", + "value": { + "message": "Name x already exists" + } + }, + "UserNotExists": { + "summary": "User does not exist", + "value": { + "message": "User with identifier x not found" + } + }, + "ContactgroupNotExists": { + "summary": "Contact Group does not exist", + "value": { + "message": "Contact Group with identifier x does not exist" + } + }, + "InvalidAddressType": { + "summary": "Invalid address type", + "value": { + "message": "Invalid request body: undefined address type x given" + } + }, + "InvalidAddressFormat": { + "summary": "Invalid address format", + "value": { + "message": "Invalid request body: expects addresses to be an array" + } + }, + "InvalidContactgroupUUID": { + "summary": "Invalid Contact Group UUID", + "value": { + "message": "Invalid request body: the group identifier invalid_uuid is not a valid UUID" + } + }, + "InvalidContactgroupUUIDFormat": { + "summary": "Invalid Contact Group UUID format", + "value": { + "message": "Invalid request body: an invalid group identifier format given" + } + }, + "InvalidDefaultChannelUUID": { + "summary": "Invalid default_channel UUID", + "value": { + "message": "Invalid request body: given default_channel is not a valid UUID" + } + }, + "InvalidEmailAddress": { + "summary": "Invalid email address", + "value": { + "message": "Invalid request body: an invalid email address given" + } + }, + "InvalidEmailAddressFormat": { + "summary": "Invalid email address format", + "value": { + "message": "Invalid request body: an invalid email address format given" + } + }, + "InvalidGroupsFormat": { + "summary": "Invalid groups format", + "value": { + "message": "Invalid request body: expects groups to be an array" + } + }, + "MissingAddress": { + "summary": "Missing address", + "value": { + "message": "Invalid request body: Address according to default_channel type x is required" + } + }, + "UsernameAlreadyExists": { + "summary": "Username already exists", + "value": { + "message": "Username x already exists" + } + }, + "IdentifierMismatch": { + "summary": "Identifier mismatch", + "value": { + "message": "Identifier mismatch" + } + }, + "IdentifierNotFound": { + "summary": "Identifier not found", + "value": { + "message": "Identifier not found" + } + }, + "IdentifierPayloadIdMissmatch": { + "summary": "Identifier and payload Id missmatch", + "value": { + "message": "Identifier mismatch: the Payload id must be different from the URL identifier" + } + }, + "InvalidContentType": { + "summary": "Invalid content type", + "value": { + "message": "Invalid request header: Content-Type must be application/json" + } + }, + "InvalidFilterParameter": { + "summary": "Invalid filter parameter", + "value": { + "message": "Invalid request parameter: Filter column x is not allowed" + } + }, + "InvalidIdentifier": { + "summary": "Identifier is not valid", + "value": { + "message": "The given identifier is not a valid UUID" + } + }, + "InvalidRequestBodyFieldFormat": { + "summary": "Invalid request body field format", + "value": { + "message": "Invalid request body: expects x to be of type y" + } + }, + "InvalidRequestBodyFormat": { + "summary": "Invalid request body format", + "value": { + "message": "Invalid request body: given content is not a valid JSON" + } + }, + "InvalidRequestBodyId": { + "summary": "Invalid request body id", + "value": { + "message": "Invalid request body: given id is not a valid UUID" + } + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the field x must be present" + } + }, + "NoIdentifierWithFilter": { + "summary": "No identifier with filter", + "value": { + "message": "Invalid request: GET with identifier and query parameters, it's not allowed to use both together." + } + }, + "UnexpectedQueryParameter": { + "summary": "Unexpected query parameter", + "value": { + "message": "Unexpected query parameter: Filter is only allowed for GET requests" + } + } + }, + "securitySchemes": { + "BasicAuth": { + "type": "http", + "description": "Basic authentication for API access", + "scheme": "basic" + } + } + }, + "security": [ + { + "BasicAuth": [] + } + ], + "tags": [ + { + "name": "Contacts", + "description": "Operations related to notification Contacts" + }, + { + "name": "Contact Groups", + "description": "Operations related to notification Contact Groups" + }, + { + "name": "Channels", + "description": "Operations related to notification Channels" + } + ] +} \ No newline at end of file diff --git a/doc/api/v1.md b/doc/api/v1.md new file mode 100644 index 000000000..70b12557c --- /dev/null +++ b/doc/api/v1.md @@ -0,0 +1,9 @@ +--- +hide: + - toc +--- +# API V1 + +Refer to the OpenAPI specification below for detailed information on each endpoint. + +!!swagger api-v1-public.json!! diff --git a/library/Notifications/Api/ApiCore.php b/library/Notifications/Api/ApiCore.php new file mode 100644 index 000000000..1a97808bf --- /dev/null +++ b/library/Notifications/Api/ApiCore.php @@ -0,0 +1,105 @@ +assertValidRequest($request); + + return $this->handleRequest($request); + } + + /** + * Get allowed HTTP methods for the API. + * + * @return array + */ + public function getAllowedMethods(): array + { + $methods = []; + + foreach (HttpMethod::cases() as $method) { + if (method_exists($this, $method->lowercase())) { + $methods[] = $method->uppercase(); + } + } + + return $methods; + } + + /** + * Validate the incoming request. + * + * Override to implement specific request validation logic. + * + * @param ServerRequestInterface $request The incoming server-request to validate. + * + * @return void + */ + protected function assertValidRequest(ServerRequestInterface $request): void + { + } + + /** + * Create a Response object. + * + * @param int $status The HTTP status code. + * @param array $headers An associative array of HTTP headers. + * @param ?(StreamInterface|resource|string) $body The response body. + * @param string $version The HTTP version. + * @param ?string $reason The reason phrase (optional). + * + * @return ResponseInterface + */ + protected function createResponse( + int $status = 200, + array $headers = [], + $body = null, + string $version = '1.1', + ?string $reason = null + ): ResponseInterface { + $headers['Content-Type'] = 'application/json'; + + return new Response($status, $headers, $body, $version, $reason); + } +} diff --git a/library/Notifications/Api/EndpointInterface.php b/library/Notifications/Api/EndpointInterface.php new file mode 100644 index 000000000..eb3774b67 --- /dev/null +++ b/library/Notifications/Api/EndpointInterface.php @@ -0,0 +1,11 @@ +getAttribute('version'); + $endpoint = $request->getAttribute('endpoint'); + $class = sprintf('Icinga\\Module\\Notifications\\Api\\%s\\%s', $version, $endpoint); + + if (! class_exists($class) || ! is_subclass_of($class, RequestHandlerInterface::class)) { + throw new HttpNotFoundException("Endpoint $endpoint not found"); + } + + $endpointHandler = new $class(); + + return $handler->handle($request->withAttribute('endpointHandler', $endpointHandler)); + } +} diff --git a/library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php b/library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php new file mode 100644 index 000000000..eb10d2eca --- /dev/null +++ b/library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php @@ -0,0 +1,28 @@ +getAttribute('endpointHandler'); + + if (! $endpointHandler instanceof RequestHandlerInterface) { + return $handler->handle($request); + } + return $request->getAttribute('endpointHandler')->handle($request); + } +} diff --git a/library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php b/library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php new file mode 100644 index 000000000..abe6fd102 --- /dev/null +++ b/library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php @@ -0,0 +1,53 @@ +handle($request); + } catch (HttpExceptionInterface $e) { + return new Response( + $e->getStatusCode(), + array_merge($e->getHeaders(), ['Content-Type' => 'application/json']), + Json::sanitize(['message' => $e->getMessage()]) + ); + } catch (InvalidFilterParameterException $e) { + return new Response( + 400, + ['Content-Type' => 'application/json'], + Json::sanitize([ + 'message' => $e->getMessage() + ]) + ); + } catch (Throwable $e) { + Logger::error($e); + Logger::debug(IcingaException::getConfidentialTraceAsString($e)); + return new Response( + 500, + ['Content-Type' => 'application/json'], + Json::sanitize(['message' => 'An error occurred, please check the log.']) + ); + } + } +} diff --git a/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php b/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php new file mode 100644 index 000000000..f326d2d8e --- /dev/null +++ b/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php @@ -0,0 +1,74 @@ +legacyRequest = $legacyRequest; + } + + /** + * @throws HttpBadRequestException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ( + ! $this->legacyRequest->isApiRequest() + && strtolower($this->legacyRequest->getParam('endpoint')) !== (new OpenApi())->getEndpoint() + ) { + throw new HttpBadRequestException('No API request'); + } + + $httpMethod = $this->legacyRequest->getMethod(); + $serverRequest = (new ServerRequest( + $httpMethod, + $this->legacyRequest->getRequestUri(), + serverParams: $this->legacyRequest->getServer() + )) + ->withAttribute('route_params', $this->legacyRequest->getParams()); + + try { + if ($contentType = $this->legacyRequest->getHeader('Content-Type')) { + $serverRequest = $serverRequest->withHeader('Content-Type', $contentType); + } + + $requestBody = $this->legacyRequest->getPost(); + } catch (JsonDecodeException) { + throw new HttpBadRequestException('Invalid request body: given content is not a valid JSON'); + } catch (\Zend_Controller_Request_Exception) { + throw new HttpBadRequestException('Invalid request header: Content-Type must be application/json'); + } + + if ($httpMethod === 'POST' || $httpMethod === 'PUT') { + $serverRequest = $serverRequest->withParsedBody($requestBody); + } else { + if (! empty($requestBody)) { + throw new HttpBadRequestException( + 'Invalid request body: body is only allowed for POST and PUT requests' + ); + } + } + + return $handler->handle($serverRequest); + } +} diff --git a/library/Notifications/Api/Middleware/MiddlewarePipeline.php b/library/Notifications/Api/Middleware/MiddlewarePipeline.php new file mode 100644 index 000000000..2418b1c2f --- /dev/null +++ b/library/Notifications/Api/Middleware/MiddlewarePipeline.php @@ -0,0 +1,93 @@ + + */ + private SplQueue $pipeline; + + /** + * @param MiddlewareInterface[] $middlewares + */ + public function __construct( + array $middlewares, + ) { + $this->pipeline = new SplQueue(); + foreach ($middlewares as $middleware) { + $this->pipe($middleware); + } + } + + /** + * Add middleware to the pipeline. + * + * @param MiddlewareInterface $middleware + * + * @return $this + */ + public function pipe(MiddlewareInterface $middleware): self + { + $this->pipeline->enqueue($middleware); + + return $this; + } + + /** + * Handle the request and process the middleware pipeline. + * This method is used to process the entire pipeline with a real request. + * The request is passed to the first middleware in the pipeline. + * The response is returned from the last middleware in the pipeline. + * If no middleware is left in the pipeline, a 404 Not Found response is returned. + * + * @param ServerRequestInterface $request + * + * @return ResponseInterface + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $middleware = $this->pipeline->dequeue(); + + if ($middleware === null) { + return new Response(404, ['Content-Type' => 'application/json'], 'Not Found'); + } + + return $middleware->process($request, $this); + } + + /** + * Execute the middleware pipeline. + * This method is used to process the entire pipeline with a fake request. + * + * @param ServerRequestInterface|null $request + * + * @return ResponseInterface + */ + public function execute(ServerRequestInterface $request = null): ResponseInterface + { + if ($request === null) { + $request = new ServerRequest('GET', '/'); // initial dummy request + } + + return $this->handle($request); + } +} diff --git a/library/Notifications/Api/Middleware/RoutingMiddleware.php b/library/Notifications/Api/Middleware/RoutingMiddleware.php new file mode 100644 index 000000000..d3b7f24e6 --- /dev/null +++ b/library/Notifications/Api/Middleware/RoutingMiddleware.php @@ -0,0 +1,33 @@ +getAttribute('route_params'); + $version = ucfirst($params['version']); + $endpoint = ucfirst(Str::camel($params['endpoint'])); + $identifier = $params['identifier'] ?? null; + + return $handler->handle( + $request + ->withAttribute('version', ucfirst($version)) + ->withAttribute('endpoint', ucfirst($endpoint)) + ->withAttribute('identifier', $identifier !== null ? strtolower($identifier) : null) + ); + } +} diff --git a/library/Notifications/Api/Middleware/ValidationMiddleware.php b/library/Notifications/Api/Middleware/ValidationMiddleware.php new file mode 100644 index 000000000..5b140daa2 --- /dev/null +++ b/library/Notifications/Api/Middleware/ValidationMiddleware.php @@ -0,0 +1,125 @@ +getAttribute('endpointHandler'); + + if (! $endpointHandler instanceof EndpointInterface) { + throw new HttpBadRequestException("No endpoint resolved"); + } + + $request = $this->validateHttpMethod($request, $endpointHandler); + + $this->assertValidRequest($request); + + return $handler->handle($request); + } + + /** + * Validate the HTTP method of the request. + * + * @param ServerRequestInterface $request + * @param EndpointInterface $endpointHandler + * + * @return ServerRequestInterface + * + * @throws HttpException + */ + private function validateHttpMethod( + ServerRequestInterface $request, + EndpointInterface $endpointHandler + ): ServerRequestInterface { + try { + $httpMethod = HttpMethod::fromRequest($request); + } catch (ValueError) { + throw (new HttpException(405, sprintf('HTTP method %s is not supported', $request->getMethod()))) + ->setHeader('Allow', implode(', ', $endpointHandler->getAllowedMethods())); + } + + $request = $request->withAttribute('httpMethod', $httpMethod); + + if (! in_array($httpMethod->uppercase(), $endpointHandler->getAllowedMethods())) { + throw (new HttpException( + 405, + sprintf( + 'Method %s is not supported for endpoint %s', + $httpMethod->uppercase(), + $endpointHandler->getEndpoint() + ) + )) + ->setHeader('Allow', implode(', ', $endpointHandler->getAllowedMethods())); + } + + return $request; + } + + /** + * Assert that the request has a valid format. + * + * @param ServerRequestInterface $request + * + * @return void + * + * @throws HttpBadRequestException + */ + private function assertValidRequest(ServerRequestInterface $request): void + { + $httpMethod = $request->getAttribute('httpMethod'); + $identifier = $request->getAttribute('identifier'); + $queryFilter = $request->getUri()->getQuery(); + + if ($httpMethod !== HttpMethod::GET && ! empty($queryFilter)) { + throw new HttpBadRequestException( + 'Unexpected query parameter: Filter is only allowed for GET requests' + ); + } + + if ($httpMethod === HttpMethod::GET && ! empty($identifier) && ! empty($queryFilter)) { + throw new HttpBadRequestException(sprintf( + 'Invalid request: %s with identifier and query parameters, it\'s not allowed to use both together.', + $httpMethod->uppercase() + )); + } + + if ( + ! in_array($httpMethod, [HttpMethod::PUT, HttpMethod::POST]) + && (! empty($request->getBody()->getSize()) || ! empty($request->getParsedBody())) + ) { + throw new HttpBadRequestException('Invalid request: Body is only allowed for POST and PUT requests'); + } + + if (in_array($httpMethod, [HttpMethod::PUT, HttpMethod::DELETE]) && empty($identifier)) { + throw new HttpBadRequestException("Invalid request: Identifier is required"); + } + + if (! empty($identifier) && (strlen($identifier) !== 36 || ! Uuid::isValid($identifier))) { + throw new HttpBadRequestException('The given identifier is not a valid UUID'); + } + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php new file mode 100644 index 000000000..aa2cf5e35 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php @@ -0,0 +1,53 @@ + $entityName . ' created successfully', + ] + ), + ], + headers: [ + new OA\Header( + header: 'X-Resource-Identifier', + description: 'The identifier of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Header( + header: 'Location', + description: 'The URL of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'url', + example: 'notifications/api/v1/' . strtolower($entityName) . 's/{identifier}', + ) + ) + ], + links: [ + new OA\Link( + link: 'Get' . $entityName . 'ByIdentifier', + operationId: 'get' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Retrieve the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Update' . $entityName . 'ByIdentifier', + operationId: 'update' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Update the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Delete' . $entityName . 'ByIdentifier', + operationId: 'delete' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Delete the created contact using the X-Resource-Identifier header' + ), + ] + ); + + parent::__construct( + path: $path, + operationId: ($hasIdentifier ? 'replace' : 'create') . $entityName, + description: $description, + summary: $summary, + requestBody: $requestBody, + tags: $tags, + parameters: $parameters, + responses: array_merge([ + $successResponse, + new ErrorResponse( + response: 400, + examples: array_merge([ + new ResponseExample('InvalidRequestBodyFormat'), + new ResponseExample('UnexpectedQueryParameter'), + ], $examples400 ?? []) + ), + new ErrorResponse( + response: 415, + examples: [ + new ResponseExample('InvalidContentType'), + ] + ), + new ErrorResponse( + response: 422, + examples: array_merge( + [ + new OA\Examples( + example: $entityName . ' AlreadyExists', + summary: $entityName . ' already exists', + value: ['message' => $entityName . ' already exists'], + ), + new ResponseExample('InvalidRequestBodyFieldFormat'), + new ResponseExample('InvalidRequestBodyId'), + new ResponseExample('MissingRequiredRequestBodyField') + ], + $examples422 ?? [] + ) + ), + + ], $responses ?? []), + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php new file mode 100644 index 000000000..637507dad --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php @@ -0,0 +1,140 @@ + $entityName . ' created successfully', + ] + ), + ], + headers: [ + new OA\Header( + header: 'X-Resource-Identifier', + description: 'The identifier of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Header( + header: 'Location', + description: 'The URL of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'url', + example: 'notifications/api/v1/' . strtolower($entityName) . 's/{identifier}', + ) + ) + ], + links: [ + new OA\Link( + link: 'Get' . $entityName . 'ByIdentifiere', + operationId: 'get' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Retrieve the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Update' . $entityName . 'ByIdentifier', + operationId: 'update' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Update the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Delete' . $entityName . 'ByIdentifier', + operationId: 'delete' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Delete the created contact using the X-Resource-Identifier header' + ), + ] + ), + new SuccessResponse( + response: 204, + description: $entityName . ' updated successfully', + ), + new ErrorResponse( + response: 400, + examples: array_merge([ + new ResponseExample('InvalidRequestBodyFormat'), + new ResponseExample('UnexpectedQueryParameter'), + ], $examples400 ?? []) + ), + new Error404Response($entityName), + new ErrorResponse( + response: 415, + examples: [ + new ResponseExample('InvalidContentType'), + ] + ), + new ErrorResponse( + response: 422, + examples: array_merge( + [ + new OA\Examples( + example: $entityName . ' AlreadyExists', + summary: $entityName . ' already exists', + value: ['message' => $entityName . ' already exists'], + ), + new ResponseExample('InvalidRequestBodyFieldFormat'), + new ResponseExample('InvalidRequestBodyId'), + new ResponseExample('IdentifierMismatch'), + new ResponseExample('MissingRequiredRequestBodyField') + ], + $examples422 ?? [] + ) + ), + + ], + $responses ?? [] + ), + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php new file mode 100644 index 000000000..3b0ce933b --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php @@ -0,0 +1,40 @@ + $parameter ?? Generator::UNDEFINED, + 'name' => $name ?? Generator::UNDEFINED, + 'description' => $description ?? Generator::UNDEFINED, + 'in' => 'path', + 'required' => $required ?? true, + 'schema' => $schema, + ]; + + $params = $example !== null ? array_merge($params, ['example' => $example]) : $params; + + parent::__construct(...$params); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php new file mode 100644 index 000000000..82cec69d8 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php @@ -0,0 +1,39 @@ + $parameter, + 'name' => $name, + 'description' => $description, + 'in' => 'query', + 'required' => $required ?? false, + 'schema' => $schema, + ]; + + $params = $example !== null ? array_merge($params, ['example' => $example]) : $params; + + parent::__construct(...$params); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php new file mode 100644 index 000000000..4c1b7bac6 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php @@ -0,0 +1,33 @@ + $endpointName . ' not found'], + ) + ], + ref: '#/components/schemas/ErrorResponse' + ) + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php new file mode 100644 index 000000000..5e10f896c --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php @@ -0,0 +1,61 @@ + 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 409 => 'Conflict', + 415 => 'Unsupported Media Type', + 422 => 'Unprocessable Entity', + ]; + + public function __construct( + object|string|null $ref = null, + int $response = 400, + ?array $examples = null, + ?array $headers = null, + ?array $links = null, + ) { + if (isset(self::ERROR_RESPONSES[$response])) { + $description = self::ERROR_RESPONSES[$response]; + } else { + throw new \InvalidArgumentException('Unexpected response type'); + } + + parent::__construct( + ref: $ref, + response: $response, + description: $description, + headers: $headers, + content: new OA\JsonContent( + examples: $examples, + ref: '#/components/schemas/ErrorResponse', + ), + links: $links, + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php new file mode 100644 index 000000000..2a21f2632 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php @@ -0,0 +1,79 @@ + 'Identifier mismatch'], +)] +#[OA\Examples( + example: 'IdentifierNotFound', + summary: 'Identifier not found', + value: ['message' => 'Identifier not found'] +)] +#[OA\Examples( + example: 'IdentifierPayloadIdMissmatch', + summary: 'Identifier and payload Id missmatch', + value: ['message' => 'Identifier mismatch: the Payload id must be different from the URL identifier'], +)] +#[OA\Examples( + example: 'InvalidContentType', + summary: 'Invalid content type', + value: ['message' => 'Invalid request header: Content-Type must be application/json'], +)] +#[OA\Examples( + example: 'InvalidFilterParameter', + summary: 'Invalid filter parameter', + value: ['message' => 'Invalid request parameter: Filter column x is not allowed'] +)] +#[OA\Examples( + example: 'InvalidIdentifier', + summary: 'Identifier is not valid', + value: ['message' => 'The given identifier is not a valid UUID'] +)] +#[OA\Examples( + example: 'InvalidRequestBodyFieldFormat', + summary: 'Invalid request body field format', + value: ['message' => 'Invalid request body: expects x to be of type y'], +)] +#[OA\Examples( + example: 'InvalidRequestBodyFormat', + summary: 'Invalid request body format', + value: ['message' => 'Invalid request body: given content is not a valid JSON'], +)] +#[OA\Examples( + example: 'InvalidRequestBodyId', + summary: 'Invalid request body id', + value: ['message' => 'Invalid request body: given id is not a valid UUID'], +)] +#[OA\Examples( + example: 'MissingRequiredRequestBodyField', + summary: 'Missing required request body field', + value: ['message' => 'Invalid request body: the field x must be present'], +)] +#[OA\Examples( + example: 'NoIdentifierWithFilter', + summary: 'No identifier with filter', + value: [ + 'message' => + "Invalid request: GET with identifier and query parameters, it's not allowed to use both together.", + ], +)] +#[OA\Examples( + example: 'UnexpectedQueryParameter', + summary: 'Unexpected query parameter', + value: ['message' => 'Unexpected query parameter: Filter is only allowed for GET requests'] +)] +class ResponseExample extends Examples +{ + public function __construct(string $name) + { + parent::__construct(example: $name, ref: '#/components/examples/' . $name); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php new file mode 100644 index 000000000..e6f2af366 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php @@ -0,0 +1,49 @@ + 'OK', + 201 => 'Created', + 204 => 'No Content', + ]; + + public function __construct( + int|string|null $response = null, + ?string $description = null, + ?array $examples = null, + ?array $headers = null, + ?array $links = null, + ) { + if (! isset(self::SUCCESS_RESPONSES[$response])) { + throw new \InvalidArgumentException('Unexpected response type'); + } + + $content = $response !== 204 + ? new OA\JsonContent( + examples: $examples, + ref: '#/components/schemas/SuccessResponse', + ) + : null; + + parent::__construct( + response: $response, + description: $description, + headers: $headers, + content: $content, + links: $links + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Schema/DefaultSchemas.php b/library/Notifications/Api/OpenApiDescriptionElement/Schema/DefaultSchemas.php new file mode 100644 index 000000000..a372ecb21 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Schema/DefaultSchemas.php @@ -0,0 +1,32 @@ +openapi->paths as $path) { + foreach ($path->operations() as $operation) { + // Avoid duplicates + $already = array_filter( + $operation->responses, + fn($resp) => $resp->response === 401 + ); + + if (! $already) { + $operation->responses[] = new OA\Response([ + 'response' => 401, + 'description' => 'Unauthorized', + ]); + } + } + } + } +} diff --git a/library/Notifications/Api/V1/ApiV1.php b/library/Notifications/Api/V1/ApiV1.php new file mode 100644 index 000000000..d594bad5f --- /dev/null +++ b/library/Notifications/Api/V1/ApiV1.php @@ -0,0 +1,226 @@ + []], + ], +)] +#[OA\Tag( + name: 'Contacts', + description: 'Operations related to notification Contacts' +)] +#[OA\Tag( + name: 'Contact Groups', + description: 'Operations related to notification Contact Groups' +)] +#[OA\Tag( + name: 'Channels', + description: 'Operations related to notification Channels' +)] +#[OA\SecurityScheme( + securityScheme: 'BasicAuth', + type: 'http', + description: 'Basic authentication for API access', + scheme: 'basic', +)] +abstract class ApiV1 extends ApiCore +{ + /** + * This constant defines the version of the API. + * + * @var string + */ + public const VERSION = 'v1'; + + /** + * @throws HttpBadRequestException If the request is not valid. + */ + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $identifier = $request->getAttribute('identifier'); + $queryFilter = $request->getUri()->getQuery(); + + return match ($request->getAttribute('httpMethod')) { + HttpMethod::PUT => $this->put($identifier, $this->getValidRequestBody($request)), + HttpMethod::POST => $this->post($identifier, $this->getValidRequestBody($request)), + HttpMethod::GET => $this->get($identifier, $queryFilter), + HttpMethod::DELETE => $this->delete($identifier), + }; + } + + /** + * Override this method to modify the row before it is returned in the response. + * + * @param stdClass $row + * @return void + */ + public function prepareRow(stdClass $row): void + { + } + + /** + * Create a filter from the filter string. + * + * @param string $queryFilter + * @param array $allowedColumns + * @param string $idColumnName + * + * @return array|bool Returns an array of filter rules or false if no filter string is provided. + * + * @throws HttpBadRequestException If the filter string cannot be parsed. + */ + protected function assembleFilter(string $queryFilter, array $allowedColumns, string $idColumnName): array|bool + { + if (empty($queryFilter)) { + return false; + } + + try { + $filterRule = QueryString::fromString($queryFilter) + ->on( + QueryString::ON_CONDITION, + function (Condition $condition) use ($allowedColumns, $idColumnName) { + $column = $condition->getColumn(); + if (! in_array($column, $allowedColumns)) { + throw new InvalidFilterParameterException($column); + } + + if ($column === 'id') { + if (! Uuid::isValid($condition->getValue())) { + throw new HttpBadRequestException('The given filter id is not a valid UUID'); + } + + $condition->setColumn($idColumnName); + } + } + )->parse(); + + return FilterProcessor::assembleFilter($filterRule); + } catch (Exception $e) { + if ($e instanceof InvalidFilterParameterException) { + throw $e; + } + + throw new HttpBadRequestException($e->getMessage()); + } + } + + /** + * Validate that the request has a JSON content type and return the parsed JSON content. + * + * @param ServerRequestInterface $request The request-object to validate. + * + * @return array The validated JSON content as an associative array. + * + * @throws HttpBadRequestException If the content type is not application/json. + */ + private function getValidRequestBody(ServerRequestInterface $request): array + { + if ($request->getHeaderLine('Content-Type') !== 'application/json') { + throw new HttpBadRequestException('Invalid request header: Content-Type must be application/json'); + } + + if (! empty($parsedBody = $request->getParsedBody()) && is_array($parsedBody)) { + return $parsedBody; + } + + $msgPrefix = 'Invalid request body: '; + $body = $request->getBody()->getContents(); + + if (empty($body)) { + throw new HttpBadRequestException($msgPrefix . 'given content is empty'); + } + + try { + $validBody = Json::decode($body, true); + } catch (JsonDecodeException) { + throw new HttpBadRequestException($msgPrefix . 'given content is not a valid JSON'); + } + + return $validBody; + } + + /** + * Generates a streamable response for large datasets. + * + * Enables efficient delivery of data by yielding results in batches. + * + * @param Select $stmt The SQL select statement to execute. + * @param int $batchSize The number of rows to fetch in each batch (default is 500). + * + * @return Generator Yields JSON-encoded strings representing the content. + * + * @throws JsonEncodeException + */ + protected function createContentGenerator( + Select $stmt, + int $batchSize = 500 + ): Generator { + $stmt->limit($batchSize); + $offset = 0; + + if ($stmt->getOrderBy() === null) { + $stmt->orderBy('id'); + } + + yield '{"data":['; + $res = Database::get()->select($stmt->offset($offset)); + do { + /** @var stdClass $row */ + foreach ($res as $i => $row) { + $this->prepareRow($row); + + if ($i > 0 || $offset !== 0) { + yield ","; + } + + yield Json::sanitize($row); + } + + $offset += $batchSize; + $res = Database::get()->select($stmt->offset($offset)); + } while ($res->rowCount()); + + yield ']}'; + } +} diff --git a/library/Notifications/Api/V1/Channels.php b/library/Notifications/Api/V1/Channels.php new file mode 100644 index 000000000..384022e45 --- /dev/null +++ b/library/Notifications/Api/V1/Channels.php @@ -0,0 +1,318 @@ + 'https://example.com/webhook?token=abc123', + ], + oneOf: [ + new OA\Schema(ref: '#/components/schemas/EmailChannelConfig'), + new OA\Schema(ref: '#/components/schemas/WebhookChannelConfig'), + new OA\Schema(ref: '#/components/schemas/RocketChatChannelConfig'), + ], + )] + protected array $config; + + public function getEndpoint(): string + { + return 'channels'; + } + + /** + * Get a channel by UUID. + * + * @param string|null $identifier + * @param string $queryFilter + * @return ResponseInterface + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Get( + entityName: 'Channel', + path: '/channels/{identifier}', + description: 'Retrieve detailed information about a specific notification Channel using its UUID', + summary: 'Get a specific Channel by its UUID', + tags: ['Channels'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Channel to retrieve', + identifierSchema: 'ChannelUUID' + ), + ], + responses: [] + )] + public function get(?string $identifier, string $queryFilter): ResponseInterface + { + $stmt = (new Select()) + ->distinct() + ->from('channel ch') + ->columns([ + 'channel_id' => 'ch.id', + 'id' => 'ch.external_uuid', + 'name', + 'type', + 'config' + ]); + + if ($identifier === null) { + return $this->getPlural($queryFilter, $stmt); + } + + $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = Database::get()->fetchOne($stmt); + + if ($result === false) { + throw new HttpNotFoundException('Channel not found'); + } + + $this->prepareRow($result); + + return $this->createResponse(body: Json::sanitize(['data' => $result])); + } + + /** + * List channels or get specific channels by filter parameters. + * + * @param string $queryFilter + * @param Select $stmt + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws JsonEncodeException + */ + #[OadV1GetPlural( + entityName: 'Channel', + path: '/channels', + description: 'List all notification channels or filter by parameters', + summary: 'List all notification channels or filter by parameters', + tags: ['Channels'], + parameters: [ + new QueryParameter( + name: 'id', + description: 'Filter by channel UUID', + schema: new SchemaUUID(entityName: 'Channel'), + ), + new QueryParameter( + name: 'name', + description: 'Filter by channel name (supports partial matches)', + ), + new QueryParameter( + name: 'type', + description: 'Filter by channel type', + identifierSchema: 'ChannelTypes', + ), + ], + responses: [] + )] + private function getPlural(string $queryFilter, Select $stmt): ResponseInterface + { + $filter = $this->assembleFilter( + $queryFilter, + ['id', 'name', 'type'], + 'external_uuid' + ); + + if ($filter !== false) { + $stmt->where($filter); + } + + return $this->createResponse(body: $this->createContentGenerator($stmt)); + } + + /** + * Get the channel id with the given identifier + * + * @param string $channelIdentifier + * + * @return int|false + */ + public static function getChannelId(string $channelIdentifier): int|false + { + /** @var stdClass|false $channel */ + $channel = Database::get()->fetchOne( + (new Select()) + ->from('channel') + ->columns('id') + ->where(['external_uuid = ?' => $channelIdentifier]) + ); + + return $channel->id ?? false; + } + + /** + * Get the type of the channel + * + * @param string $channelId + * + * @return string + */ + public static function getChannelType(string $channelId): string + { + /** @var stdClass|false $channel */ + $channel = Database::get()->fetchOne( + (new Select()) + ->from('channel') + ->columns('type') + ->where(['id = ?' => $channelId]) + ); + + return $channel->type; + } + + public function prepareRow(stdClass $row): void + { + $row->config = Json::decode($row->config, true); + unset($row->channel_id); + } +} diff --git a/library/Notifications/Api/V1/ContactGroups.php b/library/Notifications/Api/V1/ContactGroups.php new file mode 100644 index 000000000..666d98048 --- /dev/null +++ b/library/Notifications/Api/V1/ContactGroups.php @@ -0,0 +1,813 @@ + 'string', 'name' => 'string']; + + #[OA\Examples( + example: 'InvalidUserFormat', + summary: 'Invalid user format', + value: ['message' => 'Invalid request body: expects users to be an array'] + )] + #[OA\Examples( + example: 'InvalidUserUUID', + summary: 'Invalid user UUID', + value: ['message' => 'Invalid request body: the user identifier X is not a valid UUID'] + )] + #[OA\Examples( + example: 'NameAlreadyExists', + summary: 'Name already exists', + value: ['message' => 'Name x already exists'] + )] + #[OA\Examples( + example: 'UserNotExists', + summary: 'User does not exist', + value: ['message' => 'User with identifier x not found'] + )] + protected array $specificResponses = []; + #[OA\Property( + ref: '#/components/schemas/ContactgroupUUID', + )] + protected string $id; + #[OA\Property( + description: 'The name of the Contact Group', + type: 'string', + example: 'My Contact Group', + )] + protected string $name; + #[OA\Property( + description: 'List of user identifiers (UUIDs) that belong to this Contact Group', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ContactUUID') + )] + protected ?array $users; + + + public function getEndpoint(): string + { + return 'contact-groups'; + } + + /** + * Get a Contact Group by UUID. + * + * @param string|null $identifier + * @param string $queryFilter + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Get( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Retrieve detailed information about a specific notification Contact Group using its UUID', + summary: 'Get a specific Contact Group by its UUID', + tags: ['Contact Groups'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact Group to retrieve', + identifierSchema: 'ContactgroupUUID' + ), + ], + responses: [] + )] + public function get(?string $identifier, string $queryFilter): ResponseInterface + { + $stmt = (new Select()) + ->distinct() + ->from('contactgroup cg') + ->columns([ + 'contactgroup_id' => 'cg.id', + 'id' => 'cg.external_uuid', + 'name' + ]) + ->where(['cg.deleted = ?' => 'n']); + + if ($identifier === null) { + return $this->getPlural($queryFilter, $stmt); + } + + $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = Database::get()->fetchOne($stmt); + + if ($result === false) { + throw new HttpNotFoundException('Contact Group not found'); + } + + $this->prepareRow($result); + + return $this->createResponse(body: Json::sanitize(['data' => $result])); + } + + /** + * List Contact Groups or get specific Contact Groups by filter parameters. + * + * @param string $queryFilter + * @param Select $stmt + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws JsonEncodeException + */ + #[OadV1GetPlural( + entityName: 'Contactgroup', + path: '/contact-groups', + description: 'Retrieve all Contact Groups or filter them by parameters.', + summary: 'List all Contact Groups or filter by parameters', + tags: ['Contact Groups'], + parameters: [ + new QueryParameter( + name: 'id', + description: 'Filter by Contact Group UUID', + schema: new SchemaUUID(entityName: 'Contactgroup'), + ), + new QueryParameter( + name: 'name', + description: 'Filter by Contact Group name', + ), + ], + responses: [] + )] + private function getPlural(string $queryFilter, Select $stmt): ResponseInterface + { + $filter = $this->assembleFilter( + $queryFilter, + ['id', 'name'], + 'external_uuid' + ); + + if ($filter !== false) { + $stmt->where($filter); + } + + return $this->createResponse(body: $this->createContentGenerator($stmt)); + } + + /** + * Update a Contact Group by UUID. + * + * @param string $identifier + * @param requestBody $requestBody + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpException + * @throws JsonEncodeException + */ + #[OadV1Put( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Update a Contact Group by UUID, if it doesn\'t exist, it will be created. \ + The identifier must be the same as the payload id', + summary: 'Update a Contact Group by UUID', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + ref: '#/components/schemas/Contactgroup' + ) + ), + tags: ['Contact Groups'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact Group to update', + identifierSchema: 'ContactgroupUUID' + ) + ], + examples422: [ + new ResponseExample('InvalidUserFormat'), + new ResponseExample('InvalidUserUUID'), + new ResponseExample('NameAlreadyExists'), + new ResponseExample('UserNotExists'), + ] + )] + public function put(string $identifier, array $requestBody): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $this->assertValidRequestBody($requestBody); + + if ($identifier !== $requestBody['id']) { + throw new HttpException(422, 'Identifier mismatch'); + } + + Database::get()->beginTransaction(); + + if (($contactgroupId = self::getGroupId($identifier)) !== null) { + $this->updateContactgroup($requestBody, $contactgroupId); + $result = $this->createResponse(204); + } else { + $this->addContactgroup($requestBody); + $result = $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact Group created successfully']) + ); + } + + Database::get()->commitTransaction(); + + return $result; + } + + /** + * Create or replace a Contact Group + * + * @param string|null $identifier The identifier of the Contact Group to update, or null to create a new one + * @param requestBody $requestBody The request body containing the Contact Group data + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws HttpException + * @throws JsonEncodeException + */ + #[OadV1Post( + entityName: 'Contactgroup', + path: '/contact-groups', + description: 'Create a new Contact Group', + summary: 'Create a new Contact Group', + tags: ['Contact Groups'], + examples422: [ + new ResponseExample('InvalidUserFormat'), + new ResponseExample('InvalidUserUUID'), + new ResponseExample('NameAlreadyExists'), + new ResponseExample('UserNotExists'), + ] + )] + #[OadV1Post( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Replace a Contact Group by UUID, the identifier must be different from the payload id', + summary: 'Replace a Contact Group by UUID', + tags: ['Contact Groups'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact Group to create', + identifierSchema: 'ContactgroupUUID' + ) + ], + examples422: [ + new ResponseExample('InvalidUserFormat'), + new ResponseExample('InvalidUserUUID'), + new ResponseExample('NameAlreadyExists'), + new ResponseExample('UserNotExists'), + ] + )] + public function post(?string $identifier, array $requestBody): ResponseInterface + { + $this->assertValidRequestBody($requestBody); + + Database::get()->beginTransaction(); + + $emptyIdentifier = $identifier === null; + + if (! $emptyIdentifier) { + if ($identifier === $requestBody['id']) { + throw new HttpException( + 422, + 'Identifier mismatch: the Payload id must be different from the URL identifier' + ); + } + + $groupId = $this->getGroupId($identifier); + + if ($groupId === null) { + throw new HttpNotFoundException('Contact Group not found'); + } + } + + if ($this->getGroupId($requestBody['id']) !== null) { + throw new HttpException(422, 'Contact Group already exists'); + } + + if (! $emptyIdentifier) { + $this->removeContactgroup($groupId); + } + + $this->addContactgroup($requestBody); + Database::get()->commitTransaction(); + + return $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact Group created successfully']) + ); + } + + /** + * Remove the Contact Group with the given id + * + * @param string $identifier + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + */ + #[OadV1Delete( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Delete a Contact Group by UUID', + summary: 'Delete a Contact Group by UUID', + tags: ['Contact Groups'], + )] + public function delete(string $identifier): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $contactgroupId = self::getGroupId($identifier); + + if ($contactgroupId === null) { + throw new HttpNotFoundException('Contact Group not found'); + } + + Database::get()->beginTransaction(); + $this->removeContactgroup($contactgroupId); + Database::get()->commitTransaction(); + + return $this->createResponse(204); + } + + /** + * Fetch the group identifiers of the contact with the given id from the contactgroup_member table + * + * @param int $contactId + * + * @return string[] + */ + public static function fetchGroupIdentifiers(int $contactId): array + { + return Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member cgm') + ->columns('cg.external_uuid') + ->joinLeft('contactgroup cg', 'cg.id = cgm.contactgroup_id') + ->where(['cgm.contact_id = ?' => $contactId, 'cgm.deleted = ?' => 'n']) + ->groupBy('cg.external_uuid') + ); + } + + /** + * Get the group id with the given identifier + * + * @param string $identifier + * + * @return ?int + */ + public static function getGroupId(string $identifier): ?int + { + /** @var stdClass|false $group */ + $group = Database::get()->fetchOne( + (new Select()) + ->from('contactgroup') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); +// +// if ($group === false) { +// $deletedGroup = Database::get() +// ->fetchCol('SELECT id FROM contactgroup WHERE external_uuid = ?', [$identifier]); +// +// if (! empty($deletedGroup)) { +// throw new HttpException(422, 'Contactgroup id is not available: ' . $identifier); +// } +// } + + return $group->id ?? null; +// $group = Database::get() +// ->fetchCol('SELECT id FROM contactgroup WHERE external_uuid = ?', [$identifier]); +// +// return $group[0] ?? null; + } + + /** + * Remove the Contact Group with the given id and all its references + * + * @param int $id + * + * @return void + */ + private function removeContactgroup(int $id): void + { + $markAsDeleted = ['changed_at' => (int) (new DateTime())->format("Uv"), 'deleted' => 'y']; + $markEntityAsDeleted = array_merge( + $markAsDeleted, + ['external_uuid' => substr_replace(Uuid::uuid4()->toString(), '0', 14, 1)] + ); + $updateCondition = ['contactgroup_id = ?' => $id, 'deleted = ?' => 'n']; + + $rotationAndMemberIds = Database::get()->fetchPairs( + RotationMember::on(Database::get()) + ->columns(['id', 'rotation_id']) + ->filter(Filter::equal('contactgroup_id', $id)) + ->assembleSelect() + ); + + $rotationMemberIds = array_keys($rotationAndMemberIds); + $rotationIds = array_values($rotationAndMemberIds); + + Database::get()->update('rotation_member', $markAsDeleted + ['position' => null], $updateCondition); + + if (! empty($rotationMemberIds)) { + Database::get()->update( + 'timeperiod_entry', + $markAsDeleted, + ['rotation_member_id IN (?)' => $rotationMemberIds, 'deleted = ?' => 'n'] + ); + } + + if (! empty($rotationIds)) { + $rotationIdsWithOtherMembers = Database::get()->fetchCol( + RotationMember::on(Database::get()) + ->columns('rotation_id') + ->filter( + Filter::all( + Filter::equal('rotation_id', $rotationIds), + Filter::unequal('contactgroup_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveRotations = array_diff($rotationIds, $rotationIdsWithOtherMembers); + + if (! empty($toRemoveRotations)) { + $rotations = Rotation::on(Database::get()) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $toRemoveRotations)); + + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); + } + } + } + + $escalationIds = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter(Filter::equal('contactgroup_id', $id)) + ->assembleSelect() + ); + + Database::get()->update('rule_escalation_recipient', $markAsDeleted, $updateCondition); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter( + Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('contactgroup_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + Database::get()->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } + } + + Database::get()->update('contactgroup_member', $markAsDeleted, $updateCondition); + + Database::get()->update( + 'contactgroup', + $markEntityAsDeleted, + ['id = ?' => $id, 'deleted = ?' => 'n'] + ); + } + + /** + * Validate the request body for required fields and types + * + * @param requestBody $requestBody + * + * @return void + * + * @throws HttpBadRequestException + * @throws HttpException + */ + private function assertValidRequestBody(array $requestBody): void + { + $msgPrefix = 'Invalid request body: '; + + foreach (self::REQUIRED_FIELD_TYPES as $field => $type) { + if (empty($requestBody[$field])) { + throw new HttpException(422, $msgPrefix . "the field $field must be present"); + } + + if ($type === 'string' && ! is_string($requestBody[$field])) { + throw new HttpException(422, $msgPrefix . "expects $field to be of type string"); + } + } + + if (! Uuid::isValid($requestBody['id'])) { + throw new HttpException(422, $msgPrefix . 'given id is not a valid UUID'); + } + + if (! empty($requestBody['users'])) { + if (! is_array($requestBody['users'])) { + throw new HttpException(422, $msgPrefix . 'expects users to be an array'); + } + + foreach ($requestBody['users'] as $user) { + if (! is_string($user) || ! Uuid::isValid($user)) { + throw new HttpException(422, sprintf( + '%sthe user identifier %s is not a valid UUID', + $msgPrefix, + $user + )); + } + //TODO: check if users exist, here? + } + } + } + + /** + * Add a new Contact Group with the given data + * + * @param requestBody $requestBody + * + * @return void + * @throws HttpException + */ + private function addContactgroup(array $requestBody): void + { + Database::get()->insert('contactgroup', [ + 'name' => $requestBody['name'], + 'external_uuid' => $requestBody['id'], + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $id = Database::get()->lastInsertId(); + + if (! empty($requestBody['users'])) { + $this->addUsers($id, $requestBody['users']); + } + } + + private function updateContactgroup(array $requestBody, int $contactgroupId): void + { + $storedValues = $this->fetchDbValues($contactgroupId); + + $changedAt = (int) (new DateTime())->format("Uv"); + + if ($requestBody['name'] !== $storedValues['group_name']) { + Database::get()->update( + 'contactgroup', + ['name' => $requestBody['name'], 'changed_at' => $changedAt], + ['id = ?' => $contactgroupId] + ); + } + + $storedContacts = []; + if (! empty($storedValues['group_members'])) { + $storedContacts = explode(',', $storedValues['group_members']); + } + + $newContacts = []; + if (! empty($requestBody['users'])) { + foreach ($requestBody['users'] as $identifier) { + $contactId = Contacts::getContactId($identifier); + if ($contactId === null) { + throw new HttpException(422, sprintf('User with identifier %s not found', $identifier)); + } + $newContacts[] = $contactId; + } + } + + $toDelete = array_diff($storedContacts, $newContacts); + $toAdd = array_diff($newContacts, $storedContacts); + + if (! empty($toDelete)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'y'], + [ + 'contactgroup_id = ?' => $contactgroupId, + 'contact_id IN (?)' => $toDelete, + 'deleted = ?' => 'n' + ] + ); + } + + if (! empty($toAdd)) { + $contactsMarkedAsDeleted = Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member') + ->columns(['contact_id']) + ->where([ + 'contactgroup_id = ?' => $contactgroupId, + 'deleted = ?' => 'y', + 'contact_id IN (?)' => $toAdd + ]) + ); + + $toAdd = array_diff($toAdd, $contactsMarkedAsDeleted); + foreach ($toAdd as $contactId) { + Database::get()->insert( + 'contactgroup_member', + [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => $changedAt + ] + ); + } + + if (! empty($contactsMarkedAsDeleted)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'n'], + [ + 'contactgroup_id = ?' => $contactgroupId, + 'contact_id IN (?)' => $contactsMarkedAsDeleted + ] + ); + } + } + } + + /** + * Add the given users as contactgroup_member with the given id + * + * @param int $contactgroupId + * @param string[] $users + * + * @return void + * + * @throws HttpException + */ + private function addUsers(int $contactgroupId, array $users): void + { + foreach ($users as $identifier) { + $contactId = Contacts::getContactId($identifier); + + if ($contactId === null) { + throw new HttpException(422, sprintf('User with identifier %s not found', $identifier)); + } + + Database::get()->insert('contactgroup_member', [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + public function prepareRow(stdClass $row): void + { + $row->users = Contacts::fetchUserIdentifiers($row->contactgroup_id); + + unset($row->contactgroup_id); + } + + /** + * Assert that the name is unique + * + * @param string $name + * @param ?int $contactgroupId The id of the Contact Group to exclude + * + * @return void + * + * @throws HttpException if the username already exists + */ + private function assertUniqueName(string $name, int $contactgroupId = null): void + { + $stmt = (new Select()) + ->from('contactgroup') + ->columns('1') + ->where(['name = ?' => $name]); + + if ($contactgroupId) { + $stmt->where(['id != ?' => $contactgroupId]); + } + + $user = Database::get()->fetchOne($stmt); + + if ($user) { + throw new HttpException(422, sprintf('Username %s already exists', $name)); + } + } + + /** + * Fetch the values from the database + * + * @param int $contactgroupId + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(int $contactgroupId): array + { + $query = Contactgroup::on(Database::get()) + ->columns(['id', 'name']) + ->filter(Filter::equal('id', $contactgroupId)); + + $group = $query->first(); + if ($group === null) { + throw new HttpNotFoundException('Contact group not found'); + } + + $groupMembers = []; + foreach ($group->contactgroup_member as $contact) { + $groupMembers[] = $contact->contact_id; + } + + return [ + 'group_name' => $group->name, + 'group_members' => implode(',', $groupMembers) + ]; + } +} diff --git a/library/Notifications/Api/V1/Contacts.php b/library/Notifications/Api/V1/Contacts.php new file mode 100644 index 000000000..e1671757b --- /dev/null +++ b/library/Notifications/Api/V1/Contacts.php @@ -0,0 +1,1069 @@ + + * } + */ +#[OA\Schema( + schema: 'Contact', + description: 'Schema that represents a contact in the Icinga Notifications API', + required: [ + 'id', + 'full_name', + 'default_channel', + 'addresses' + ], + type: 'object', + additionalProperties: false, +)] +#[OA\Schema( + schema: 'Addresses', + description: 'Schema that represents a contact\'s addresses', + properties: [ + new OA\Property( + property: 'email', + description: "User's email address", + type: 'string', + format: 'email', + ), + new OA\Property( + property: 'rocketchat', + description: 'Rocket.Chat identifier or URL', + type: 'string', + example: 'rocketchat.example.com', + ), + new OA\Property( + property: 'webhook', + description: 'Comma-separated list of webhook URLs or identifiers', + type: 'string', + example: 'https://example.com/webhook', + ), + ], + type: 'object', + additionalProperties: false, +)] +#[SchemaUUID( + entityName: 'Contact', + example: '9e868ad0-e774-465b-8075-c5a07e8f0726', +)] +#[SchemaUUID( + entityName: 'NewContact', + example: '52668ad0-e774-465b-8075-c5a07e8f0726', +)] +class Contacts extends ApiV1 implements RequestHandlerInterface, EndpointInterface +{ + public const REQUIRED_FIELDS = [ + 'id', + 'full_name', + 'default_channel', + 'addresses' + ]; + public const REQUIRED_FIELD_TYPES = [ + 'id' => 'string', + 'full_name' => 'string', + 'default_channel' => 'string', + 'addresses' => 'object', + ]; + + #[OA\Examples( + example: 'ContactgroupNotExists', + summary: 'Contact Group does not exist', + value: ['message' => 'Contact Group with identifier x does not exist'] + )] + #[OA\Examples( + example: 'InvalidAddressType', + summary: 'Invalid address type', + value: ['message' => 'Invalid request body: undefined address type x given'] + )] + #[OA\Examples( + example: 'InvalidAddressFormat', + summary: 'Invalid address format', + value: ['message' => 'Invalid request body: expects addresses to be an array'] + )] + #[OA\Examples( + example: 'InvalidContactgroupUUID', + summary: 'Invalid Contact Group UUID', + value: ['message' => 'Invalid request body: the group identifier invalid_uuid is not a valid UUID'] + )] + #[OA\Examples( + example: 'InvalidContactgroupUUIDFormat', + summary: 'Invalid Contact Group UUID format', + value: ['message' => 'Invalid request body: an invalid group identifier format given'] + )] + #[OA\Examples( + example: 'InvalidDefaultChannelUUID', + summary: 'Invalid default_channel UUID', + value: ['message' => 'Invalid request body: given default_channel is not a valid UUID'] + )] + #[OA\Examples( + example: 'InvalidEmailAddress', + summary: 'Invalid email address', + value: ['message' => 'Invalid request body: an invalid email address given'] + )] + #[OA\Examples( + example: 'InvalidEmailAddressFormat', + summary: 'Invalid email address format', + value: ['message' => 'Invalid request body: an invalid email address format given'] + )] + #[OA\Examples( + example: 'InvalidGroupsFormat', + summary: 'Invalid groups format', + value: ['message' => 'Invalid request body: expects groups to be an array'] + )] + #[OA\Examples( + example: 'MissingAddress', + summary: 'Missing address', + value: ['message' => 'Invalid request body: Address according to default_channel type x is required'] + )] + #[OA\Examples( + example: 'UsernameAlreadyExists', + summary: 'Username already exists', + value: ['message' => 'Username x already exists'] + )] + protected array $specificResponses = []; + #[OA\Property( + ref: '#/components/schemas/ContactUUID', + )] + protected string $id; + #[OA\Property( + description: 'The full name of the contact', + type: 'string', + example: 'Icinga User', + )] + protected string $full_name; + #[OA\Property( + description: 'The username of the contact', + type: 'string', + maxLength: 254, + example: 'icingauser', + )] + protected ?string $username = null; + #[OA\Property( + ref: '#/components/schemas/ChannelUUID', + description: 'The default channel UUID for the contact' + )] + protected string $default_channel; + #[OA\Property( + description: 'List of group UUIDs the contact belongs to', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/ContactgroupUUID', + description: 'Group UUIDs the contact belongs to', + ) + )] + protected ?array $groups = null; + #[OA\Property( + ref: '#/components/schemas/Addresses', + description: 'Contact addresses by type', + )] + protected ?array $addresses = null; + + public function getEndpoint(): string + { + return 'contacts'; + } + + /** + * Get a contact by UUID. + * + * @param string|null $identifier + * @param string $queryFilter + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Get( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Retrieve detailed information about a specific notification Contact using its UUID', + summary: 'Get a specific Contact by its UUID', + tags: ['Contacts'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact to retrieve', + identifierSchema: 'ContactUUID' + ), + ], + )] + public function get(?string $identifier, string $queryFilter): ResponseInterface + { + $stmt = (new Select()) + ->distinct() + ->from('contact co') + ->columns([ + 'contact_id' => 'co.id', + 'id' => 'co.external_uuid', + 'full_name', + 'username', + 'default_channel' => 'ch.external_uuid', + ]) + ->joinLeft('contact_address ca', 'ca.contact_id = co.id') + ->joinLeft('channel ch', 'ch.id = co.default_channel_id') + ->where(['co.deleted = ?' => 'n']); + + if ($identifier === null) { + return $this->getPlural($queryFilter, $stmt); + } + + $stmt->where(['co.external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = Database::get()->fetchOne($stmt); + + if ($result === false) { + throw new HttpNotFoundException('Contact not found'); + } + + $this->prepareRow($result); + + return $this->createResponse(body: Json::sanitize(['data' => $result])); + } + + /** + * List contacts or get specific contacts by filter parameters. + * + * @param string $queryFilter + * @param Select $stmt + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws JsonEncodeException + */ + #[OadV1GetPlural( + entityName: 'Contact', + path: '/contacts', + description: 'Retrieve all Contacts or filter them by parameters.', + summary: 'List all Contacts or filter by parameters', + tags: ['Contacts'], + parameters: [ + new QueryParameter( + name: 'id', + description: 'Filter Contacts by UUID', + schema: new SchemaUUID(entityName: 'Contact'), + ), + new QueryParameter( + name: 'full_name', + description: 'Filter Contacts by full name', + ), + new QueryParameter( + name: 'username', + description: 'Filter Contacts by username', + schema: new OA\Schema(type: 'string', maxLength: 254) + ), + ], + responses: [] + )] + private function getPlural(string $queryFilter, Select $stmt): ResponseInterface + { + $filter = $this->assembleFilter( + $queryFilter, + ['id', 'full_name', 'username'], + 'co.external_uuid' + ); + + if ($filter !== false) { + $stmt->where($filter); + } + + return $this->createResponse(body: $this->createContentGenerator($stmt)); + } + + /** + * Update a contact by UUID. + * + * @param string $identifier + * @param requestBody $requestBody + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpException + * @throws JsonEncodeException + */ + #[OadV1Put( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Update a Contact by UUID, if it doesn\'t exist, it will be created. \ + The identifier must be the same as the payload id', + summary: 'Update a Contact by UUID', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + ref: '#/components/schemas/Contact' + ) + ), + tags: ['Contacts'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact to Update', + identifierSchema: 'ContactUUID' + ) + ], + examples422: [ + new ResponseExample('ContactgroupNotExists'), + new ResponseExample('InvalidAddressFormat'), + new ResponseExample('InvalidAddressType'), + new ResponseExample('InvalidContactgroupUUID'), + new ResponseExample('InvalidContactgroupUUIDFormat'), + new ResponseExample('InvalidDefaultChannelUUID'), + new ResponseExample('InvalidEmailAddress'), + new ResponseExample('InvalidEmailAddressFormat'), + new ResponseExample('InvalidGroupsFormat'), + new ResponseExample('MissingAddress'), + new ResponseExample('UsernameAlreadyExists'), + ] + )] + public function put(string $identifier, array $requestBody): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + Database::get()->beginTransaction(); + + $this->assertValidRequestBody($requestBody); + + if ($identifier !== $requestBody['id']) { + throw new HttpException(422, 'Identifier mismatch'); + } + + if (($contactId = self::getContactId($identifier)) !== null) { + $this->updateContact($requestBody, $contactId); + + $result = $this->createResponse(204); + } else { + $this->addContact($requestBody); + $result = $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact created successfully']) + ); + } + + Database::get()->commitTransaction(); + + return $result; + } + + /** + * Create a new contact. + * + * @param string|null $identifier + * @param requestBody $requestBody + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Post( + entityName: 'Contact', + path: '/contacts', + description: 'Create a new Contact', + summary: 'Create a new Contact', + tags: ['Contacts'], + examples422: [ + new ResponseExample('ContactgroupNotExists'), + new ResponseExample('InvalidAddressType'), + new ResponseExample('InvalidAddressFormat'), + new ResponseExample('InvalidContactgroupUUID'), + new ResponseExample('InvalidContactgroupUUIDFormat'), + new ResponseExample('InvalidDefaultChannelUUID'), + new ResponseExample('InvalidEmailAddress'), + new ResponseExample('InvalidEmailAddressFormat'), + new ResponseExample('InvalidGroupsFormat'), + new ResponseExample('MissingAddress'), + new ResponseExample('UsernameAlreadyExists'), + ] + )] + #[OadV1Post( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Replace a Contact by UUID, the identifier must be different from the payload id', + summary: 'Replace a Contact by UUID', + tags: ['Contacts'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the contact to create', + identifierSchema: 'ContactUUID' + ) + ], + examples422: [ + new ResponseExample('ContactgroupNotExists'), + new ResponseExample('InvalidAddressType'), + new ResponseExample('InvalidAddressFormat'), + new ResponseExample('InvalidContactgroupUUID'), + new ResponseExample('InvalidContactgroupUUIDFormat'), + new ResponseExample('InvalidDefaultChannelUUID'), + new ResponseExample('InvalidEmailAddress'), + new ResponseExample('InvalidEmailAddressFormat'), + new ResponseExample('InvalidGroupsFormat'), + new ResponseExample('MissingAddress'), + new ResponseExample('UsernameAlreadyExists'), + ] + )] + public function post(?string $identifier, array $requestBody): ResponseInterface + { + $this->assertValidRequestBody($requestBody); + + Database::get()->beginTransaction(); + + $emptyIdentifier = $identifier === null; + + if (! $emptyIdentifier) { + if ($identifier === $requestBody['id']) { + throw new HttpException( + 422, + 'Identifier mismatch: the Payload id must be different from the URL identifier' + ); + } + + $contactId = $this->getContactId($identifier); + + if ($contactId === null) { + throw new HttpNotFoundException('Contact not found'); + } + } + + if ($this->getContactId($requestBody['id']) !== null) { + throw new HttpException(422, 'Contact already exists'); + } + + if (! $emptyIdentifier) { + $this->removeContact($contactId); + } + + $this->addContact($requestBody); + Database::get()->commitTransaction(); + + return $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact created successfully']) + ); + } + + /** + * Remove the contact with the given id + * + * @param string $identifier + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + */ + #[OadV1Delete( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Delete a Contact by UUID', + summary: 'Delete a Contact by UUID', + tags: ['Contacts'], + )] + public function delete(string $identifier): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $contactId = $this->getContactId($identifier); + + if ($contactId === null) { + throw new HttpNotFoundException('Contact not found'); + } + + Database::get()->beginTransaction(); + $this->removeContact($contactId); + Database::get()->commitTransaction(); + + return $this->createResponse(204); + } + + public function prepareRow(stdClass $row): void + { + $row->groups = ContactGroups::fetchGroupIdentifiers($row->contact_id); + $row->addresses = self::fetchContactAddresses($row->contact_id) ?: new stdClass(); + + unset($row->contact_id); + } + + /** + * Fetch the addresses of the contact with the given id + * + * @param int $contactId + * + * @return array + */ + public static function fetchContactAddresses(int $contactId): array + { + /** @var array $addresses */ + $addresses = Database::get()->fetchPairs( + (new Select()) + ->from('contact_address') + ->columns(['type', 'address']) + ->where(['contact_id = ?' => $contactId]) + ); + + return $addresses; + } + + /** + * Get the contact id with the given identifier + * + * @param string $identifier + * + * @return ?int Returns null, if contact does not exist + */ + public static function getContactId(string $identifier): ?int + { + /** @var stdClass|false $contact */ + $contact = Database::get()->fetchOne( + (new Select()) + ->from('contact') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); + +// if ($contact === false) { +// $deletedContact = Database::get() +// ->fetchCol('SELECT id FROM contact WHERE external_uuid = ?', [$identifier]); +// +// if (! empty($deletedContact)) { +// throw new HttpException(422, 'Contact id is not available: ' . $identifier); +// } +// } + + return $contact->id ?? null; + +// $contact = Database::get() +// ->fetchCol('SELECT id FROM contact WHERE external_uuid = ?', [$identifier]); +// +// return $contact[0] ?? null; + } + + /** + * Add the groups to the given contact + * + * @param int $contactId + * @param string[] $groups + * + * @return void + * @throws HttpException + */ + private function addGroups(int $contactId, array $groups): void + { + foreach ($groups as $groupIdentifier) { + $groupId = ContactGroups::getGroupId($groupIdentifier); + + if ($groupId === null) { + throw new HttpException( + 422, + sprintf('Contact Group with identifier %s does not exist', $groupIdentifier) + ); + } + + Database::get()->insert('contactgroup_member', [ + 'contact_id' => $contactId, + 'contactgroup_id' => $groupId, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + /** + * Add the addresses to the given contact + * + * @param int $contactId + * @param array $addresses + * + * @return void + */ + private function addAddresses(int $contactId, array $addresses): void + { + foreach ($addresses as $type => $address) { + Database::get()->insert('contact_address', [ + 'contact_id' => $contactId, + 'type' => $type, + 'address' => $address, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + /** + * Add a new contact with the given data + * + * @param requestBody $requestBody + * + * @return void + * @throws HttpException + */ + private function addContact(array $requestBody): void + { + if (! empty($requestBody['username'])) { + $this->assertUniqueUsername($requestBody['username']); + } + + Database::get()->insert('contact', [ + 'full_name' => $requestBody['full_name'], + 'username' => $requestBody['username'] ?? null, + 'default_channel_id' => Channels::getChannelId($requestBody['default_channel']), + 'external_uuid' => $requestBody['id'], + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $contactId = Database::get()->lastInsertId(); + + if (! empty($requestBody['addresses'])) { + $this->addAddresses($contactId, $requestBody['addresses']); + } + + if (! empty($requestBody['groups'])) { + $this->addGroups($contactId, $requestBody['groups']); + } + } + + private function updateContact(array $requestBody, int $contactId): void + { + if (! empty($requestBody['username'])) { + $this->assertUniqueUsername($requestBody['username'], $contactId); + } + + $changedAt = (int) (new DateTime())->format("Uv"); + Database::get()->update('contact', [ + 'full_name' => $requestBody['full_name'], + 'username' => $requestBody['username'] ?? null, + 'default_channel_id' => Channels::getChannelId($requestBody['default_channel']), + 'changed_at' => $changedAt, + ], ['id = ?' => $contactId]); + + $markAsDeleted = ['deleted' => 'y']; + Database::get()->update( + 'contact_address', + $markAsDeleted, + ['contact_id = ?' => $contactId, 'deleted = ?' => 'n'] + ); + + if (! empty($requestBody['addresses'])) { + $this->addAddresses($contactId, $requestBody['addresses']); + } + + $storedValues = $this->fetchDbValues($contactId); + $storedContacts = []; + if (! empty($storedValues['group_members'])) { + $storedContacts = explode(',', $storedValues['group_members']); + } + + $newContactgroups = []; + if (! empty($requestBody['groups'])) { + foreach ($requestBody['groups'] as $identifier) { + $contactgroupId = ContactGroups::getGroupId($identifier); + if ($contactgroupId === null) { + throw new HttpException( + 422, + sprintf('Contact Group with identifier %s does not exist', $identifier) + ); + } + $newContactgroups[] = $contactgroupId; + } + } + + $toDelete = array_diff($storedContacts, $newContactgroups); + $toAdd = array_diff($newContactgroups, $storedContacts); + + if (! empty($toDelete)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'y'], + [ + 'contactgroup_id = ?' => $toDelete, + 'contact_id IN (?)' => $contactId, + 'deleted = ?' => 'n' + ] + ); + } + + if (! empty($toAdd)) { + $contactgroupsMarkedAsDeleted = Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member') + ->columns(['contactgroup_id']) + ->where([ + 'contact_id = ?' => $contactId, + 'deleted = ?' => 'y', + 'contactgroup_id IN (?)' => $toAdd + ]) + ); + + $toAdd = array_diff($toAdd, $contactgroupsMarkedAsDeleted); + foreach ($toAdd as $contactgroupId) { + Database::get()->insert( + 'contactgroup_member', + [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => $changedAt + ] + ); + } + + if (! empty($contactgroupsMarkedAsDeleted)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'n'], + [ + 'contact_id = ?' => $contactId, + 'contactgroup_id IN (?)' => $contactgroupsMarkedAsDeleted + ] + ); + } + } + } + + /** + * Remove the contact with the given id + * + * @param int $id + * + * @return void + */ + private function removeContact(int $id): void + { + //TODO: "remove rotations|escalations with no members" taken from form. Is it properly? + + $markAsDeleted = ['changed_at' => (int) (new DateTime())->format("Uv"), 'deleted' => 'y']; + $markEntityAsDeleted = array_merge( + $markAsDeleted, + ['external_uuid' => substr_replace(Uuid::uuid4()->toString(), '0', 14, 1)] + ); + $updateCondition = ['contact_id = ?' => $id, 'deleted = ?' => 'n']; + + $rotationAndMemberIds = Database::get()->fetchPairs( + RotationMember::on(Database::get()) + ->columns(['id', 'rotation_id']) + ->filter(Filter::equal('contact_id', $id)) + ->assembleSelect() + ); + + $rotationMemberIds = array_keys($rotationAndMemberIds); + $rotationIds = array_values($rotationAndMemberIds); + + Database::get()->update('rotation_member', $markAsDeleted + ['position' => null], $updateCondition); + + if (! empty($rotationMemberIds)) { + Database::get()->update( + 'timeperiod_entry', + $markAsDeleted, + ['rotation_member_id IN (?)' => $rotationMemberIds, 'deleted = ?' => 'n'] + ); + } + + if (! empty($rotationIds)) { + $rotationIdsWithOtherMembers = Database::get()->fetchCol( + RotationMember::on(Database::get()) + ->columns('rotation_id') + ->filter( + Filter::all( + Filter::equal('rotation_id', $rotationIds), + Filter::unequal('contact_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveRotations = array_diff($rotationIds, $rotationIdsWithOtherMembers); + + if (! empty($toRemoveRotations)) { + $rotations = Rotation::on(Database::get()) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $toRemoveRotations)); + + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); + } + } + } + + $escalationIds = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter(Filter::equal('contact_id', $id)) + ->assembleSelect() + ); + + Database::get()->update('rule_escalation_recipient', $markAsDeleted, $updateCondition); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter( + Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('contact_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + Database::get()->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } + } + + Database::get()->update('contactgroup_member', $markAsDeleted, $updateCondition); + Database::get()->update('contact_address', $markAsDeleted, $updateCondition); + + Database::get()->update('contact', $markEntityAsDeleted + ['username' => null], ['id = ?' => $id]); + } + + /** + * Assert that the username is unique + * + * @param string $username + * @param ?int $contactId The id of the contact to exclude + * + * @return void + * + * @throws HttpException if the username already exists + */ + private function assertUniqueUsername(string $username, int $contactId = null): void + { + $stmt = (new Select()) + ->from('contact') + ->columns('1') + ->where(['username = ?' => $username]); + + if ($contactId) { + $stmt->where(['id != ?' => $contactId]); + } + + $user = Database::get()->fetchOne($stmt); + + if ($user) { + throw new HttpException(422, sprintf('Username %s already exists', $username)); + } + } + + /** + * Validate the request body for required fields and types + * + * @param array $requestBody + * + * @return void + * + * @throws HttpBadRequestException + * @throws HttpException + */ + private function assertValidRequestBody(array $requestBody): void + { + $msgPrefix = 'Invalid request body: '; + + foreach (self::REQUIRED_FIELD_TYPES as $field => $type) { + if (empty($requestBody[$field])) { + throw new HttpException(422, $msgPrefix . "the field $field must be present"); + } + + if ($type === 'string' && ! is_string($requestBody[$field])) { + throw new HttpException(422, $msgPrefix . "expects $field to be of type string"); + } + } + + if (! Uuid::isValid($requestBody['id'])) { + throw new HttpBadRequestException($msgPrefix . 'given id is not a valid UUID'); + } + + if (! Uuid::isValid($requestBody['default_channel'])) { + throw new HttpException(422, $msgPrefix . 'given default_channel is not a valid UUID'); + } + + $channelId = Channels::getChannelId($requestBody['default_channel']); + + if ($channelId === false) { + throw new HttpException(422, 'Channel with identifier 0817d973-398e-41d7-9cd2-61cdb7ef41a3 does not exist'); + } + + $channelType = Channels::getChannelType($channelId); + + if (! is_array($requestBody['addresses']) || empty($requestBody['addresses'][$channelType])) { + throw new HttpException( + 422, + $msgPrefix . "an address according to default_channel type $channelType is required" + ); + } + + $addressTypes = array_keys($requestBody['addresses']); + + $types = Database::get()->fetchCol( + (new Select()) + ->from('available_channel_type') + ->columns('type') + ->where(['type IN (?)' => $addressTypes]) + ); + + if (count($types) !== count($addressTypes)) { + throw new HttpException( + 422, + sprintf( + $msgPrefix . 'undefined address type %s given', + implode(', ', array_diff($addressTypes, $types)) + ) + ); + } + + //TODO: is it a good idea to check valid channel types here?, if yes, + //default_channel and group identifiers must be checked here as well..404 OR 400? + if (isset($requestBody['addresses']['email'])) { + if (! is_string($requestBody['addresses']['email'])) { + throw new HttpBadRequestException($msgPrefix . 'an invalid email address format given'); + } + + if ( + ! empty($requestBody['addresses']['email']) + && ! (new EmailAddressValidator())->isValid($requestBody['addresses']['email']) + ) { + throw new HttpBadRequestException($msgPrefix . 'an invalid email address given'); + } + } + + if (! empty($requestBody['username']) && ! is_string($requestBody['username'])) { + throw new HttpException(422, $msgPrefix . 'expects username to be of type string'); + } + + if (! empty($requestBody['groups'])) { + if (! is_array($requestBody['groups'])) { + throw new HttpException(422, $msgPrefix . 'expects groups to be of type array'); + } + + foreach ($requestBody['groups'] as $group) { + if (! is_string($group)) { + throw new HttpException(422, $msgPrefix . 'an invalid group identifier format given'); + } elseif (! Uuid::isValid($group)) { + throw new HttpException( + 422, + sprintf($msgPrefix . 'the group identifier %s is not a valid UUID', $group) + ); + } + } + } + } + + /** + * Fetch the user(contact) identifiers of the Contact Group with the given id from the contactgroup_member table + * + * @param int $contactgroupId + * + * @return string[] + */ + public static function fetchUserIdentifiers(int $contactgroupId): array + { + return Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member cgm') + ->columns('co.external_uuid') + ->joinLeft('contact co', 'co.id = cgm.contact_id') + ->where(['cgm.contactgroup_id = ?' => $contactgroupId, 'cgm.deleted = ?' => 'n']) + ->groupBy('co.external_uuid') + ); + } + + /** + * Fetch the values from the database + * + * @param int $contactId + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(int $contactId): array + { + $query = Contact::on(Database::get()) + ->columns(['id', 'full_name', 'default_channel_id']) + ->filter(Filter::equal('id', $contactId)); + + $contact = $query->first(); + if ($contact === null) { + throw new HttpNotFoundException('Contact contact not found'); + } + + $groupMembers = []; + foreach ($contact->contactgroup_member as $group) { + $groupMembers[] = $group->contactgroup_id; + } + + return [ + 'group_members' => implode(',', $groupMembers) + ]; + } +} diff --git a/library/Notifications/Common/HttpMethod.php b/library/Notifications/Common/HttpMethod.php new file mode 100644 index 000000000..b25993733 --- /dev/null +++ b/library/Notifications/Common/HttpMethod.php @@ -0,0 +1,48 @@ +name; + } + + /** + * Returns the current enum case as string in lowercase. + * + * @return string + */ + public function lowercase(): string + { + return $this->value; + } + + /** + * Retrieves an enum instance from a ServerRequestInterface by extracting the HTTP method. + * + * @param ServerRequestInterface $request The server request containing the HTTP method. + * + * @return HttpMethod The enum instance corresponding to the provided method. + */ + public static function fromRequest(ServerRequestInterface $request): self + { + return self::from(strtolower($request->getMethod())); + } +} diff --git a/library/Notifications/Common/PsrLogger.php b/library/Notifications/Common/PsrLogger.php new file mode 100644 index 000000000..3a7542e5f --- /dev/null +++ b/library/Notifications/Common/PsrLogger.php @@ -0,0 +1,68 @@ + ERROR + * notice -> INFO + */ + private const MAP = [ + LogLevel::EMERGENCY => 'error', + LogLevel::ALERT => 'error', + LogLevel::CRITICAL => 'error', + LogLevel::ERROR => 'error', + LogLevel::WARNING => 'warning', + LogLevel::NOTICE => 'info', + LogLevel::INFO => 'info', + LogLevel::DEBUG => 'debug', + ]; + + /** + * Logs with an arbitrary level. + * + * @param string $level The log level + * @param string|Stringable $message The log message + * @param array $context Additional context variables to interpolate in the message + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + $level = strtolower((string) $level); + $icingaMethod = self::MAP[$level] ?? 'debug'; + + array_unshift($context, (string) $message); + + switch ($icingaMethod) { + case 'error': + IcingaLogger::error(...$context); + break; + case 'warning': + IcingaLogger::warning(...$context); + break; + case 'info': + IcingaLogger::info(...$context); + break; + default: + IcingaLogger::debug(...$context); + break; + } + } +} diff --git a/library/Notifications/Model/Channel.php b/library/Notifications/Model/Channel.php index 7c6a3e9cb..4f6a8b4f5 100644 --- a/library/Notifications/Model/Channel.php +++ b/library/Notifications/Model/Channel.php @@ -45,7 +45,8 @@ public function getColumns(): array 'type', 'config', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } @@ -54,7 +55,8 @@ public function getColumnDefinitions(): array return [ 'name' => t('Name'), 'type' => t('Type'), - 'changed_at' => t('Changed At') + 'changed_at' => t('Changed At'), + 'external_uuid' => t('UUID') ]; } diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index 7165bb9a6..35027ce18 100644 --- a/library/Notifications/Model/Contact.php +++ b/library/Notifications/Model/Contact.php @@ -49,7 +49,8 @@ public function getColumns(): array 'username', 'default_channel_id', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } @@ -58,7 +59,8 @@ public function getColumnDefinitions(): array return [ 'full_name' => t('Full Name'), 'username' => t('Username'), - 'changed_at' => t('Changed At') + 'changed_at' => t('Changed At'), + 'external_uuid' => t('UUID') ]; } diff --git a/library/Notifications/Model/Contactgroup.php b/library/Notifications/Model/Contactgroup.php index 3dc79481c..491c7ab4f 100644 --- a/library/Notifications/Model/Contactgroup.php +++ b/library/Notifications/Model/Contactgroup.php @@ -42,13 +42,18 @@ public function getColumns(): array return [ 'name', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } public function getColumnDefinitions(): array { - return ['name' => t('Name')]; + return [ + 'name' => t('Name'), + 'changed_at' => t('Changed At'), + 'external_uuid' => t('UUID') + ]; } public function getSearchColumns(): array diff --git a/library/Notifications/Test/ApiTestBackends.php b/library/Notifications/Test/ApiTestBackends.php new file mode 100644 index 000000000..105dd3ebe --- /dev/null +++ b/library/Notifications/Test/ApiTestBackends.php @@ -0,0 +1,340 @@ + + */ + private static array $backends = []; + + /** + * Initialize the configuration for the API tests + * + * @param Connection $db + * @param string $driver + * + * @return void + */ + abstract protected static function initializeNotificationsDb(Connection $db, string $driver): void; + + /** + * Provide the endpoints for the API tests plus their accompanying database connections + * + * @return array + */ + final public function apiTestBackends(): array + { + self::initializeBackends(); + + return self::$backends; + } + + /** + * Initialize the API test backends + * + * @return void + * + * @internal Only the trait itself should access this method + */ + final protected static function initializeBackends(): void + { + $webPath = self::getIcingaWebPath(); + + $port = 1792; + foreach (self::sharedDatabases() as $name => $connection) { + if (isset(self::$backends[$name])) { + continue; + } + + $socket = sprintf('127.0.0.1:%d', $port); + $configDir = sys_get_temp_dir() . "/notifications-api-test-backend-$port"; + + self::initializeIcingaWeb($name, $configDir); + + if (self::fork()) { + $env = ['ICINGAWEB_CONFIGDIR' => $configDir]; + + $libDir = getenv('ICINGAWEB_LIBDIR'); + if ($libDir !== false) { + $env['ICINGAWEB_LIBDIR'] = $libDir; + } + + pcntl_exec( + readlink('/proc/self/exe'), + ['-q', '-S', $socket, '-t', "$webPath/public", "$webPath/public/index.php"], + $env + ); + } else { + self::$backends[$name] = [ + $connection[0], + Url::fromRequest(request: new Request()) + ->setScheme('http') + ->setHost('127.0.0.1') + ->setPort($port) + ->setBasePath('/notifications/api') + ->setUsername('test') + ->setPassword('test') + ]; + } + + $port++; + } + } + + /** + * Initialize the Icinga Web configuration + * + * @param string $driver + * @param string $configDir + * + * @return void + * + * @internal Only the trait itself should access this method + */ + final protected static function initializeIcingaWeb(string $driver, string $configDir): void + { + $oldConfigDir = Config::$configDir; + Config::$configDir = $configDir; + + $connectionConfig = self::getConnectionConfig($driver); + + Config::app(fromDisk: true) + ->setSection('global', [ + 'config_resource' => 'web_db' + ])->setSection('logging', [ + 'log' => 'file', + 'file' => $configDir . '/icingaweb.log', + 'level' => 'debug' + ])->saveIni(); + Config::app('resources', true) + ->setSection('web_db', [ + 'type' => 'db', + 'db' => $connectionConfig->db, + 'host' => $connectionConfig->host, + 'port' => $connectionConfig->port, + 'dbname' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB'), + 'username' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_USER'), + 'password' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_PASSWORD') + ])->setSection('notifications_db', [ + 'type' => 'db', + 'db' => $connectionConfig->db, + 'host' => $connectionConfig->host, + 'port' => $connectionConfig->port, + 'dbname' => $connectionConfig->dbname, + 'username' => $connectionConfig->username, + 'password' => $connectionConfig->password + ])->saveIni(); + Config::app('roles', true)->setSection('test', [ + 'permissions' => 'module/notifications,notifications/api', + 'users' => 'test' + ])->saveIni(); + Config::app('authentication', true)->setSection('test', [ + 'backend' => 'db', + 'resource' => 'web_db' + ])->saveIni(); + Config::module('notifications', fromDisk: true)->setSection('database', [ + 'resource' => 'notifications_db' + ])->saveIni(); + + Config::$configDir = $oldConfigDir; + + if (! is_link("$configDir/enabledModules/notifications")) { + mkdir("$configDir/enabledModules", 0755, true); + symlink(realpath(__DIR__ . '/../../..'), "$configDir/enabledModules/notifications"); + } + } + + final protected static function setUpSchema(Connection $db, string $driver): void + { + $webSchema = self::getIcingaWebPath() . "/schema/$driver.schema.sql"; + + $notificationSchemaPath = getenv('ICINGA_NOTIFICATIONS_SCHEMA'); + if (! $notificationSchemaPath) { + throw new RuntimeException('Environment variable ICINGA_NOTIFICATIONS_SCHEMA is not set'); + } + + $notificationSchema = $notificationSchemaPath . "/$driver/schema.sql"; + if (! file_exists($notificationSchema)) { + throw new RuntimeException("Schema file $notificationSchema does not exist"); + } + + $webDb = self::connectToIcingaWebDb($driver); + $webDb->exec(file_get_contents($webSchema)); + self::initializeIcingaWebDb($webDb, $driver); + + $db->exec(file_get_contents($notificationSchema)); + static::initializeNotificationsDb($db, $driver); + } + + final protected static function tearDownSchema(Connection $db, string $driver): void + { + $webDb = self::connectToIcingaWebDb($driver); + + if ($driver === 'mysql') { + $webDb->exec(self::MYSQL_DROP_PROCEDURE); + $db->exec(self::MYSQL_DROP_PROCEDURE); + + $webDb->exec(self::MYSQL_PROCEDURE_CALL); + $db->exec(self::MYSQL_PROCEDURE_CALL); + } elseif ($driver === 'pgsql') { + $webDb->exec(self::PGSQL_DROP_PROCEDURE); + $db->exec(self::PGSQL_DROP_PROCEDURE); + } + } + + /** + * Initialize the Icinga Web database + * + * @param Connection $db + * @param string $driver + * + * @return void + * + * @internal Only the trait itself should access this method + */ + final protected static function initializeIcingaWebDb(Connection $db, string $driver): void + { + $db->insert('icingaweb_user', [ + 'name' => 'test', + 'active' => 1, + 'password_hash' => password_hash('test', PASSWORD_DEFAULT), + ]); + } + + /** + * Get the path to the Icinga Web installation + * + * @return string + * + * @internal Only the trait itself should access this method + */ + final protected static function getIcingaWebPath(): string + { + $webPath = getenv('ICINGAWEB_PATH'); + if ($webPath === false) { + echo "ICINGAWEB_PATH environment variable not set\n"; + exit(1); + } + + $webPath = realpath($webPath); + if (! $webPath) { + echo "ICINGAWEB_PATH environment variable is not a valid path: $webPath\n"; + exit(1); + } + + return $webPath; + } + + /** + * Connect to the Icinga Web database + * + * @param string $driver + * + * @return Connection + * + * @internal Only the trait itself should access this method + */ + final protected static function connectToIcingaWebDb(string $driver): Connection + { + return new Connection([ + 'db' => $driver, + 'host' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_HOST'), + 'port' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_PORT'), + 'username' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_USER'), + 'password' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_PASSWORD'), + 'dbname' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB') + ]); + } + + /** + * Fork the current process and return true in the child process and false in the parent process + * + * @return bool + * + * @internal Only the trait itself should access this method + */ + final protected static function fork(): bool + { + $pid = pcntl_fork(); + if ($pid == -1) { + echo "Could not fork\n"; + exit(2); + } elseif ($pid) { + register_shutdown_function(function () use ($pid) { + posix_kill($pid, SIGTERM); + }); + + return false; + } + + return true; + } +} diff --git a/library/Notifications/Test/BaseApiV1TestCase.php b/library/Notifications/Test/BaseApiV1TestCase.php new file mode 100644 index 000000000..ea5ecd1cd --- /dev/null +++ b/library/Notifications/Test/BaseApiV1TestCase.php @@ -0,0 +1,215 @@ +insert('available_channel_type', [ + 'type' => 'email', + 'name' => 'Email', + 'version' => 1, + 'author' => 'Test', + 'config_attrs' => '' + ]); + $db->insert('available_channel_type', [ + 'type' => 'webhook', + 'name' => 'Webhook', + 'version' => 1, + 'author' => 'Test', + 'config_attrs' => '' + ]); + $db->insert('available_channel_type', [ + 'type' => 'rocketchat', + 'name' => 'rocketchat', + 'version' => 1, + 'author' => 'Test', + 'config_attrs' => '' + ]); + + self::createChannels($db); + self::createContacts($db); + self::createContactGroups($db); + } + + protected static function createChannels(Connection $db): void + { + $db->insert('channel', [ + 'external_uuid' => self::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $db->insert('channel', [ + 'external_uuid' => self::CHANNEL_UUID_2, + 'name' => 'Test2', + 'type' => 'webhook', + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + + protected static function deleteChannels(Connection $db): void + { + $db->delete('channel', "external_uuid in ('" . self::CHANNEL_UUID . "', '" . self::CHANNEL_UUID_2 . "')"); + } + + protected static function createContacts(Connection $db): void + { + $channelId = $db->select( + (new Select()) + ->from('channel') + ->columns(['id']) + ->where('external_uuid = ?', self::CHANNEL_UUID) + ->limit(1) + )->fetchColumn(); + + $channelType = $db->select( + (new Select()) + ->from('channel') + ->columns(['type']) + ->where('external_uuid = ?', self::CHANNEL_UUID) + ->limit(1) + )->fetchColumn(); + + $db->insert('contact', [ + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel_id' => $channelId, + 'external_uuid' => self::CONTACT_UUID, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + $db->insert('contact', [ + 'full_name' => 'Test2', + 'username' => 'test2', + 'default_channel_id' => $channelId, + 'external_uuid' => self::CONTACT_UUID_2, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $contactIds = $db->select( + (new Select()) + ->from('contact') + ->columns('id') + ->where(['external_uuid IN (?)' => [self::CONTACT_UUID, self::CONTACT_UUID_2]]) + )->fetchAll(\PDO::FETCH_COLUMN); + + foreach ($contactIds as $contactId) { + $db->insert('contact_address', [ + 'contact_id' => $contactId, + 'type' => $channelType, + 'address' => 'test@example.com', + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + protected static function deleteContacts(Connection $db): void + { + $db->delete('contact_address'); + $db->delete('contact', "external_uuid in ('" . self::CONTACT_UUID . "', '" . self::CONTACT_UUID_2 . "')"); + } + + protected static function createContactGroups(Connection $db): void + { + $db->insert('contactgroup', [ + 'name' => 'Test', + 'external_uuid' => self::GROUP_UUID, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + $db->insert('contactgroup', [ + 'name' => 'Test2', + 'external_uuid' => self::GROUP_UUID_2, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + + protected static function deleteContactGroups(Connection $db): void + { + $db->delete('contactgroup', "external_uuid in ('" . self::GROUP_UUID . "', '" . self::GROUP_UUID_2 . "')"); + } + + protected function sendRequest( + string $method, + Url $endpoint, + string $path, + array $params = [], + ?array $json = null, + ?string $body = null, + ?array $headers = null, + ?array $options = null, + ): ResponseInterface { + $client = new Client(); + + $url = $endpoint->setPath($path)->setParams($params)->getAbsoluteUrl(); + + $options = $options ?? [ + 'http_errors' => false + ]; + $headers = $headers ?? ['Accept' => 'application/json']; + + if (! empty($headers)) { + $options['headers'] = $headers; + } + if ($json !== null) { + $options['json'] = $json; + } + if ($body !== null) { + $options['body'] = $body; + } + + return $client->request($method, $url, $options); + } + + public function jsonEncodeError(string $message): string + { + return Json::sanitize(['message' => $message]); + } + + public function jsonEncodeSuccessMessage(string $message): string + { + return Json::sanitize(['message' => $message]); + } + + public function jsonEncodeResult(array $data): string + { + return Json::sanitize(['data' => $data]); + } + + public function jsonEncodeResults(array $data): string + { + $needsWrapping = ! array_is_list($data) || count(array_filter($data, 'is_array')) !== count($data); + return Json::sanitize(['data' => $needsWrapping ? [$data] : $data]); + } +} diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index ead50ad20..e7f1f697a 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -27,6 +27,7 @@ use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; use ipl\Web\Url; +use Ramsey\Uuid\Uuid; class ContactForm extends CompatForm { @@ -229,7 +230,10 @@ public function addContact(): void $contactInfo = $this->getValues(); $changedAt = (int) (new DateTime())->format("Uv"); $this->db->beginTransaction(); - $this->db->insert('contact', $contactInfo['contact'] + ['changed_at' => $changedAt]); + $this->db->insert( + 'contact', + $contactInfo['contact'] + ['changed_at' => $changedAt, 'external_uuid' => Uuid::uuid4()->toString()] + ); $this->contactId = $this->db->lastInsertId(); foreach (array_filter($contactInfo['contact_address']) as $type => $address) { diff --git a/run.php b/run.php index 8ac420990..6f77bd7ba 100644 --- a/run.php +++ b/run.php @@ -24,3 +24,20 @@ ] ) ); + +$this->addRoute('notifications/api-plural', new Zend_Controller_Router_Route( + 'notifications/api/:version/:endpoint', + [ + 'module' => 'notifications', + 'controller' => 'api', + 'action' => 'index' + ] +)); +$this->addRoute('notifications/api-single', new Zend_Controller_Router_Route( + 'notifications/api/:version/:endpoint/:identifier', + [ + 'module' => 'notifications', + 'controller' => 'api', + 'action' => 'index' + ] +)); diff --git a/test/php/application/controllers/ApiV1ChannelsTest.php b/test/php/application/controllers/ApiV1ChannelsTest.php new file mode 100644 index 000000000..2e789b50a --- /dev/null +++ b/test/php/application/controllers/ApiV1ChannelsTest.php @@ -0,0 +1,323 @@ +jsonEncodeResults([ + 'id' => BaseApiV1TestCase::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'config' => null, + ]); + + // filter by id + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['id' => BaseApiV1TestCase::CHANNEL_UUID] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // filter by name + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // filter by type + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['type' => 'email'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // filter by all available filters together + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['id' => BaseApiV1TestCase::CHANNEL_UUID, 'name' => 'Test', 'type' => 'email'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetEverything(Connection $db, Url $endpoint): void + { + // At first, there are none + $this->deleteDefaultEntities(); + + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels' + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + + // Create new contact groups + $this->createDefaultEntities(); + + // There are two + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels' + ); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + [ + 'id' => BaseApiV1TestCase::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'config' => null, + ], + [ + 'id' => BaseApiV1TestCase::CHANNEL_UUID_2, + 'name' => 'Test2', + 'type' => 'webhook', + 'config' => null, + ], + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithAlreadyExistingIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'config' => null, + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonMatchingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels', ['name' => 'not_test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithInvalidFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels', ['nonexistingfilter' => 'value']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeError( + 'Invalid request parameter: Filter column nonexistingfilter is not allowed', + ); + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNewIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Channel not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithInvalidIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels/' . BaseApiV1TestCase::UUID_INCOMPLETE); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('The given identifier is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithIdentifierAndFilter(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request: GET with identifier and query parameters, it\'s not allowed to use both together.', + ); + + // Valid identifier and valid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID, + ['name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Invalid identifier and invalid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID, + ['nonexistingfilter' => 'value'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testRequestWithNonSupportedMethod(Connection $db, Url $endpoint): void + { + $expectedAllowHeader = 'GET'; + // General invalid method + $response = $this->sendRequest('PATCH', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('HTTP method PATCH is not supported'), + $content + ); + + // Endpoint specific invalid method + // Try to POST + $expected = $this->jsonEncodeError('Method POST is not supported for endpoint channels'); + //Try to POST without identifier + $response = $this->sendRequest('POST', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to POST with identifier + $response = $this->sendRequest('POST', $endpoint, 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to POST with filter + $response = $this->sendRequest('POST', $endpoint, 'v1/channels', ['name' => 'Test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to POST with identifier and filter + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID, + ['name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to PUT + $response = $this->sendRequest('PUT', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Method PUT is not supported for endpoint channels'), + $content + ); + + // Try to DELETE + $response = $this->sendRequest('DELETE', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Method DELETE is not supported for endpoint channels'), + $content + ); + } + + protected function deleteDefaultEntities(): void + { + $db = $this->getConnection(); + + self::deleteContacts($db); + self::deleteChannels($db); + } + + protected function createDefaultEntities(): void + { + $db = $this->getConnection(); + + self::createChannels($db); + self::createContacts($db); + } +} diff --git a/test/php/application/controllers/ApiV1ContactGroupsTest.php b/test/php/application/controllers/ApiV1ContactGroupsTest.php new file mode 100644 index 000000000..c0eaa573f --- /dev/null +++ b/test/php/application/controllers/ApiV1ContactGroupsTest.php @@ -0,0 +1,1327 @@ +sendRequest('GET', $endpoint, 'v1/contact-groups', ['name' => 'Test']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetEverything(Connection $db, Url $endpoint): void + { + // At first, there are none + self::deleteContactGroups($this->getConnection()); + + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + + // Create new contact groups + self::createContactGroups($this->getConnection()); + + // Now there are two + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ], + [ + 'id' => BaseApiV1TestCase::GROUP_UUID_2, + 'name' => 'Test2', + 'users' => [] + ] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithAlreadyExistingIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonMatchingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups', ['name' => 'not_test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + method: 'POST', + endpoint: $endpoint, + path: 'v1/contact-groups', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + method: 'POST', + endpoint: $endpoint, + path: 'v1/contact-groups', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups?id=' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_4, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithIndifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Identifier mismatch: the Payload id must be different from the URL identifier'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithExistingPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_2, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test (replaced)', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Make sure the contact group was replaced + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test (replaced)', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithAlreadyExistingPayloadId(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test (replaced)', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Let's see the contact group is available at that location + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field name must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithInvalidFieldsFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => [BaseApiV1TestCase::GROUP_UUID_3], + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => ['Test'], + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects name to be of type string'), + $content + ); + + // invalid users + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => BaseApiV1TestCase::CONTACT_UUID_3 + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects users to be an array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidOptionalData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Oh really? + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json:[ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field name must be present'), + $content + ); + + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidFieldsFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/', + json: [ + 'id' => [BaseApiV1TestCase::GROUP_UUID_3], + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => ['Test'], + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects name to be of type string'), + $content + ); + + // invalid users + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => BaseApiV1TestCase::CONTACT_UUID_3 + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects users to be an array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidOptionalData(Connection $db, Url $endpoint): void + { + // invalid users + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID_3] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('User with identifier ' . BaseApiV1TestCase::CONTACT_UUID_3 . ' not found'), + $content + ); + + // invalid user id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => ['invalid_uuid'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the user identifier invalid_uuid is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups?id=' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + + // missing name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field name must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidFieldsFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => [BaseApiV1TestCase::GROUP_UUID], + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => ['Test'], + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects name to be of type string'), + $content + ); + + // invalid users + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => BaseApiV1TestCase::CONTACT_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects users to be an array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithDifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + // indifferent id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_2, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Identifier mismatch'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Let's see the group is actually available + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test (replaced)', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + + // Oh really? + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test (replaced)', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidData(Connection $db, Url $endpoint): void + { + // invalid users + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID_3] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('User with identifier ' . BaseApiV1TestCase::CONTACT_UUID_3 . ' not found'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithValidOptionalData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Let's see the group is actually available + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + // missing name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field name must be present'), + $content + ); + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithInvalidFieldsFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => [BaseApiV1TestCase::GROUP_UUID_3], + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => ['Test'], + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects name to be of type string'), + $content + ); + + // invalid users + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => BaseApiV1TestCase::CONTACT_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects users to be an array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToChangeGroupMemberships(Connection $db, Url $endpoint): void + { + // First add a user to the group + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Check the result + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + + // Then remove it + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Again, check the result + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ]), $content); + + // And add it again + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Then verify the final result + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithKnownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups', ['name~*']); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testRequestWithNonSupportedMethod(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('PATCH', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame(['GET, POST, PUT, DELETE'], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('HTTP method PATCH is not supported'), + $content + ); + } + + public function setUp(): void + { + $db = $this->getConnection(); + + $db->delete('contactgroup_member'); + $db->delete( + 'contact', + "external_uuid NOT IN ('" . self::CONTACT_UUID . "', '" . self::CONTACT_UUID_2 . "')" + ); + $db->delete('contactgroup'); + + self::createContactGroups($db); + } +} diff --git a/test/php/application/controllers/ApiV1ContactsTest.php b/test/php/application/controllers/ApiV1ContactsTest.php new file mode 100644 index 000000000..fec29742c --- /dev/null +++ b/test/php/application/controllers/ApiV1ContactsTest.php @@ -0,0 +1,2140 @@ +sendRequest('GET', $endpoint, 'v1/contacts', ['full_name' => 'Test']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetEverything(Connection $db, Url $endpoint): void + { + // At first, there are none + self::deleteContacts($this->getConnection()); + + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + + // Create new contact + self::createContacts($this->getConnection()); + + // Now there are two + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ], + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_2, + 'full_name' => 'Test2', + 'username' => 'test2', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ], + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithAlreadyExistingIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonMatchingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts', ['full_name' => 'not_test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonExistingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts', ['unknown' => 'filter']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeError( + 'Invalid request parameter: Filter column unknown is not allowed' + ); + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithIdentifierAndFilter(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request: GET with identifier and query parameters, it\'s not allowed to use both together.' + ); + + // Valid identifier and valid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + ['full_name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Invalid identifier and invalid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + ['unknown' => 'filter'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + ['id' => BaseApiV1TestCase::CONTACT_UUID], + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_4, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithIndifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Identifier mismatch: the Payload id must be different from the URL identifier'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_2, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test (replaced)', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + // Make sure the contact was replaced + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test (replaced)', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithExistingPayloadId(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + // Let's see the contact is available at that location + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field full_name must be present'), + $content + ); + + // missing default_channel + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field default_channel must be present'), + $content + ); + + // missing address type + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field addresses must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithInvalidFieldFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => [BaseApiV1TestCase::CONTACT_UUID_3], + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => ['Test'], + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects full_name to be of type string'), + $content + ); + + // invalid default_channel + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => [BaseApiV1TestCase::CHANNEL_UUID], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects default_channel to be of type string'), + $content + ); + + // invalid addresses + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => 'test@example.com' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Invalid request body: an address according to default_channel type email is required' + ), + $content + ); + + // invalid username + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'username' => ['test'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects username to be of type string'), + $content + ); + + // invalid groups + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'groups' => BaseApiV1TestCase::GROUP_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects groups to be of type array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidOptionalData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test3', + 'username' => 'test3', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + // Oh really? + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test3', + 'username' => 'test3', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidDefaultChannel(Connection $db, Url $endpoint): void + { + // invalid default_channel uuid + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => 'invalid_uuid', + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given default_channel is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field full_name must be present'), + $content + ); + + // missing default_channel + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field default_channel must be present'), + $content + ); + + // missing address type + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field addresses must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidFieldFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/', + json: [ + 'id' => [BaseApiV1TestCase::CONTACT_UUID_3], + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => ['Test'], + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects full_name to be of type string'), + $content + ); + + // invalid default_channel + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => [BaseApiV1TestCase::CHANNEL_UUID], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects default_channel to be of type string'), + $content + ); + + // invalid addresses + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => 'test@example.com' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Invalid request body: an address according to default_channel type email is required' + ), + $content + ); + + // invalid username + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'username' => ['test'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects username to be of type string'), + $content + ); + + // invalid groups + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'groups' => BaseApiV1TestCase::GROUP_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects groups to be of type array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidAddresses(Connection $db, Url $endpoint): void + { + // with invalid address type + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'invalid' => 'value' + ] + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + "Invalid request body: an address according to default_channel type email is required" + ), + $content + ); + + // with invalid address type and matching address type + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'invalid' => 'value', + 'email' => 'test@example.com' + ] + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: undefined address type invalid given'), + $content + ); + + // mismatch address type and default_channel type + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'webhook' => 'value' + ] + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + "Invalid request body: an address according to default_channel type email is required" + ), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidOptionalData(Connection $db, Url $endpoint): void + { + // already existing username + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Username test already exists'), $content); + + // with non-existing group + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID_3], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Contact Group with identifier ' . BaseApiV1TestCase::GROUP_UUID_3 . ' does not exist' + ), + $content + ); + + // invalid group uuid + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => ['invalid_uuid'], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the group identifier invalid_uuid is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts?id=' . BaseApiV1TestCase::CONTACT_UUID_3, + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full-name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + + // missing name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field full_name must be present'), + $content + ); + + // missing default_channel + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field default_channel must be present'), + $content + ); + + // missing address type + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field addresses must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidFieldFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => [BaseApiV1TestCase::CONTACT_UUID], + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => ['Test'], + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects full_name to be of type string'), + $content + ); + + // invalid default_channel + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => [BaseApiV1TestCase::CHANNEL_UUID], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects default_channel to be of type string'), + $content + ); + + // invalid addresses + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => 'test@example.com' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Invalid request body: an address according to default_channel type email is required' + ), + $content + ); + + // invalid username + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'username' => ['test'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects username to be of type string'), + $content + ); + + // invalid groups + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'groups' => BaseApiV1TestCase::GROUP_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects groups to be of type array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithDifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Identifier mismatch'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidData(Connection $db, Url $endpoint): void + { + // invalid default_channel uuid + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID_3, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Channel with identifier ' . BaseApiV1TestCase::CHANNEL_UUID_3 . ' does not exist' + ), + $content + ); + + // invalid groups + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID_3], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Contact Group with identifier ' . BaseApiV1TestCase::GROUP_UUID_3 . ' does not exist' + ), + $content + ); + + // with invalid address type + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'invalid' => 'value' + ] + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Invalid request body: an address according to default_channel type email is required' + ), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + // missing full_name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field full_name must be present'), + $content + ); + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field id must be present'), + $content + ); + + // missing default_channel + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + ] + ); + $content = $response->getBody()->getContents(); + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field default_channel must be present'), + $content + ); + + // missing addresses + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the field addresses must be present'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithInvalidFieldFormat( + Connection $db, + Url $endpoint + ): void { + // invalid id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => [BaseApiV1TestCase::CONTACT_UUID_3], + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects id to be of type string'), + $content + ); + + // invalid name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => ['Test'], + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects full_name to be of type string'), + $content + ); + + // invalid default_channel + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => [BaseApiV1TestCase::CHANNEL_UUID], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects default_channel to be of type string'), + $content + ); + + // invalid addresses + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => 'test@example.com' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Invalid request body: an address according to default_channel type email is required' + ), + $content + ); + + // invalid username + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'username' => ['test'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects username to be of type string'), + $content + ); + + // invalid groups + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => ['email' => 'test@example.com'], + 'groups' => BaseApiV1TestCase::GROUP_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: expects groups to be of type array'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToChangeGroupMemberships(Connection $db, Url $endpoint): void + { + // First add a group to the user + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => ['email' => 'test@example.com'] + ]), $content); + + // Then remove it + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Again, check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => ['email' => 'test@example.com'] + ]), $content); + + // And add it again + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => ['email' => 'test@example.com'] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Then verify the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => ['email' => 'test@example.com'] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToChangeAddresses(Connection $db, Url $endpoint): void + { + // First add addresses to the user + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + + // Then remove one of them + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook' + ] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Again check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook' + ] + ]), $content); + + // And add it again + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Then verify the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithKnownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts', ['name~*']); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testRequestWithNonSupportedMethod(Connection $db, Url $endpoint): void + { + // General invalid method + $response = $this->sendRequest('PATCH', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame(['GET, POST, PUT, DELETE'], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('HTTP method PATCH is not supported'), + $content + ); + } + + public function setUp(): void + { + $db = $this->getConnection(); + + $db->delete('contact_address'); + $db->delete('contactgroup_member'); + $db->delete( + 'contactgroup', + "external_uuid NOT IN ('" . self::GROUP_UUID . "', '" . self::GROUP_UUID_2 . "')" + ); + $db->delete('contact'); + + self::createContacts($db); + } +}