Skip to content

Commit 17cf24a

Browse files
Feature/http api new approach (#348)
Co-authored-by: Johannes Meyer <johannes.meyer@icinga.com>
1 parent 8f59fbd commit 17cf24a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+11087
-8
lines changed

.github/workflows/php.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,36 @@ jobs:
5454
php: ['8.2', '8.3', '8.4']
5555
os: ['ubuntu-latest']
5656

57+
services:
58+
mysql:
59+
image: mariadb
60+
env:
61+
MYSQL_ROOT_PASSWORD: root
62+
MYSQL_DATABASE: icinga_unittest
63+
MYSQL_USER: icinga_unittest
64+
MYSQL_PASSWORD: icinga_unittest
65+
options: >-
66+
--health-cmd "mariadb -s -uroot -proot -e'SHOW DATABASES;' 2> /dev/null | grep icinga_unittest > test"
67+
--health-interval 10s
68+
--health-timeout 5s
69+
--health-retries 5
70+
ports:
71+
- 3306/tcp
72+
73+
pgsql:
74+
image: postgres
75+
env:
76+
POSTGRES_USER: icinga_unittest
77+
POSTGRES_PASSWORD: icinga_unittest
78+
POSTGRES_DB: icinga_unittest
79+
options: >-
80+
--health-cmd pg_isready
81+
--health-interval 10s
82+
--health-timeout 5s
83+
--health-retries 5
84+
ports:
85+
- 5432/tcp
86+
5787
steps:
5888
- name: Checkout code base
5989
uses: actions/checkout@v4
@@ -75,7 +105,36 @@ jobs:
75105
git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-library.git _libraries/ipl
76106
git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-thirdparty.git _libraries/vendor
77107
108+
- name: Checkout Icinga Notifications Daemon
109+
run: |
110+
git clone --depth 1 -b main https://github.com/Icinga/icinga-notifications.git _notifications_daemon
111+
112+
- name: Initialize Icinga Web
113+
run: |
114+
mysql --host="127.0.0.1" --port="${{ job.services.mysql.ports['3306'] }}" --user="root" --password="root" \
115+
-e "CREATE DATABASE icingaweb; CREATE USER icingaweb@'%' IDENTIFIED BY 'icingaweb'; GRANT ALL ON icingaweb.* TO icingaweb@'%';"
116+
PGPASSWORD=icinga_unittest psql --host="127.0.0.1" --port="${{ job.services.pgsql.ports['5432'] }}" \
117+
--username "icinga_unittest" -c "CREATE DATABASE icingaweb;"
118+
78119
- name: PHPUnit
79120
env:
80121
ICINGAWEB_LIBDIR: _libraries
122+
ICINGAWEB_PATH: _icingaweb2
123+
ICINGA_NOTIFICATIONS_SCHEMA: _notifications_daemon/schema
124+
MYSQL_TESTDB: icinga_unittest
125+
MYSQL_TESTDB_HOST: 127.0.0.1
126+
MYSQL_TESTDB_PORT: ${{ job.services.mysql.ports['3306'] }}
127+
MYSQL_TESTDB_USER: icinga_unittest
128+
MYSQL_TESTDB_PASSWORD: icinga_unittest
129+
MYSQL_ICINGAWEBDB: icingaweb
130+
MYSQL_ICINGAWEBDB_PASSWORD: icingaweb
131+
MYSQL_ICINGAWEBDB_USER: icingaweb
132+
PGSQL_TESTDB: icinga_unittest
133+
PGSQL_TESTDB_HOST: 127.0.0.1
134+
PGSQL_TESTDB_PORT: ${{ job.services.pgsql.ports['5432'] }}
135+
PGSQL_TESTDB_USER: icinga_unittest
136+
PGSQL_TESTDB_PASSWORD: icinga_unittest
137+
PGSQL_ICINGAWEBDB: icingaweb
138+
PGSQL_ICINGAWEBDB_PASSWORD: icinga_unittest
139+
PGSQL_ICINGAWEBDB_USER: icinga_unittest
81140
run: phpunit --bootstrap _icingaweb2/test/php/bootstrap.php
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
/* Icinga Notifications Web | (c) 2025 Icinga GmbH | GPLv2 */
4+
5+
namespace Icinga\Module\Notifications\Clicommands;
6+
7+
use FilesystemIterator;
8+
use Icinga\Application\Icinga;
9+
use Icinga\Cli\Command;
10+
use Icinga\Module\Notifications\Api\OpenApiPreprocessor\AddGlobal401Response;
11+
use Icinga\Module\Notifications\Common\PsrLogger;
12+
use OpenApi\Generator;
13+
use RecursiveDirectoryIterator;
14+
use RecursiveIteratorIterator;
15+
use RuntimeException;
16+
use SplFileInfo;
17+
use Throwable;
18+
19+
class OpenapiCommand extends Command
20+
{
21+
/**
22+
* Generate an OpenAPI JSON file from PHP attributes.
23+
*
24+
* This command scans a directory for PHP files and generates an OpenAPI JSON file using swagger-php.
25+
* The paths are relative to the notifications module directory.
26+
*
27+
* USAGE:
28+
*
29+
* icingacli notifications openapi generate [OPTIONS]
30+
*
31+
* OPTIONS
32+
*
33+
* --dir <path/> Set the path to the directory to scan for PHP files.
34+
* Default: /library/Notifications/Api/
35+
*
36+
* --exclude <comma seperated strings> Exclude files matching these strings. Wildcard is `*`
37+
*
38+
* --include <comma seperated strings> Include files matching these strings. Wildcard is `*`
39+
*
40+
* --output <path/> Set the path to the output file.
41+
* Default: /doc/api/api-v1-public.json
42+
*
43+
* --api-version <version string> Set the API version.
44+
* Default: v1
45+
* If the output path is set the --api-version option is ignored.
46+
*
47+
* --oad-version <version string> Set the OpenAPI version.
48+
* Default: 3.1.0
49+
*/
50+
public function generateAction(): void
51+
{
52+
$directoryInNotifications = $this->params->get('dir', '/library/Notifications/Api/');
53+
$exclude = $this->params->get('exclude');
54+
$include = $this->params->get('include');
55+
$outputPath = $this->params->get('output');
56+
$apiVersion = $this->params->get('api-version', 'v1');
57+
$oadVersion = $this->params->get('oad-version', '3.1.0');
58+
59+
$notificationsPath = Icinga::app()->getModuleManager()->getModule('notifications')->getBaseDir();
60+
$directory = $notificationsPath . $directoryInNotifications;
61+
62+
$baseDirectory = realpath($directory);
63+
if ($baseDirectory === false || ! is_dir($baseDirectory)) {
64+
throw new RuntimeException("Invalid directory: {$directory}");
65+
}
66+
67+
$exclude = isset($exclude) ? array_map('trim', explode(',', $exclude)) : [];
68+
$include = isset($include) ? array_map('trim', explode(',', $include)) : [];
69+
$outputPath = $notificationsPath . ($outputPath ?? '/doc/api/api-' . $apiVersion . '-public.json');
70+
71+
$files = $this->collectPhpFiles($baseDirectory, $exclude, $include);
72+
73+
echo "→ Scanning directory: $baseDirectory\n";
74+
echo "→ Found " . count($files) . " PHP files\n";
75+
76+
$generator = new Generator(new PsrLogger());
77+
$generator->setVersion($oadVersion);
78+
$generator->getProcessorPipeline()->add(new AddGlobal401Response());
79+
80+
try {
81+
$openapi = $generator->generate($files);
82+
83+
$json = $openapi->toJson(
84+
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE | JSON_PRETTY_PRINT
85+
);
86+
87+
$dir = dirname($outputPath);
88+
if (! is_dir($dir)) {
89+
mkdir($dir, 0755, true);
90+
}
91+
92+
file_put_contents($outputPath, $json);
93+
94+
echo "OpenAPI documentation written to: $outputPath\n";
95+
} catch (Throwable $e) {
96+
fwrite(STDERR, "Error generating OpenAPI: " . $e->getMessage() . "\n");
97+
exit(1);
98+
}
99+
}
100+
101+
/**
102+
* Recursively scan a directory for PHP files.
103+
*/
104+
protected function collectPhpFiles(string $baseDirectory, array $exclude, array $include): array
105+
{
106+
$baseDirectory = rtrim($baseDirectory, '/') . '/';
107+
if (! is_dir($baseDirectory)) {
108+
throw new RuntimeException("Directory $baseDirectory does not exist");
109+
}
110+
if (! is_readable($baseDirectory)) {
111+
throw new RuntimeException("Directory $baseDirectory is not readable");
112+
}
113+
114+
$files = [];
115+
$iterator = new RecursiveIteratorIterator(
116+
new RecursiveDirectoryIterator($baseDirectory, FilesystemIterator::SKIP_DOTS)
117+
);
118+
119+
/** @var SplFileInfo $file */
120+
foreach ($iterator as $file) {
121+
if (! $file->isFile() || $file->getExtension() !== 'php') {
122+
continue;
123+
}
124+
125+
$path = $file->getPathname();
126+
127+
if ($exclude !== [] && $this->matchesAnyPattern($path, $exclude)) {
128+
continue;
129+
}
130+
131+
if ($include !== [] && ! $this->matchesAnyPattern($path, $include)) {
132+
continue;
133+
}
134+
135+
$files[] = $path;
136+
}
137+
138+
if (empty($files)) {
139+
throw new RuntimeException("No PHP files found in $baseDirectory");
140+
}
141+
142+
return $files;
143+
}
144+
145+
protected function matchesAnyPattern(string $string, array $patterns): bool
146+
{
147+
foreach ($patterns as $pattern) {
148+
// Escape regex special chars except for '*'
149+
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
150+
if (preg_match($regex, $string)) {
151+
return true;
152+
}
153+
}
154+
155+
return false;
156+
}
157+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/* Icinga Notifications Web | (c) 2025 Icinga GmbH | GPLv2 */
4+
5+
namespace Icinga\Module\Notifications\Controllers;
6+
7+
use Exception;
8+
use Icinga\Exception\Http\HttpBadRequestException;
9+
use Icinga\Module\Notifications\Api\Middleware\DispatchMiddleware;
10+
use Icinga\Module\Notifications\Api\Middleware\EndpointExecutionMiddleware;
11+
use Icinga\Module\Notifications\Api\Middleware\ErrorHandlingMiddleware;
12+
use Icinga\Module\Notifications\Api\Middleware\LegacyRequestConversionMiddleware;
13+
use Icinga\Module\Notifications\Api\Middleware\MiddlewarePipeline;
14+
use Icinga\Module\Notifications\Api\Middleware\RoutingMiddleware;
15+
use Icinga\Module\Notifications\Api\Middleware\ValidationMiddleware;
16+
use Icinga\Security\SecurityException;
17+
use Icinga\Web\Request;
18+
use ipl\Web\Compat\CompatController;
19+
use Psr\Http\Message\ResponseInterface;
20+
21+
class ApiController extends CompatController
22+
{
23+
/**
24+
* Handle API requests and route them to the appropriate endpoint class.
25+
*
26+
* Processes API requests for the Notifications module, serving as the main entry point for all API interactions.
27+
*
28+
* @return never
29+
* @throws SecurityException
30+
*/
31+
public function indexAction(): never
32+
{
33+
$this->assertPermission('notifications/api');
34+
35+
$pipeline = new MiddlewarePipeline([
36+
new ErrorHandlingMiddleware(),
37+
new LegacyRequestConversionMiddleware($this->getRequest()),
38+
new RoutingMiddleware(),
39+
new DispatchMiddleware(),
40+
new ValidationMiddleware(),
41+
new EndpointExecutionMiddleware(),
42+
]);
43+
44+
$this->emitResponse($pipeline->execute());
45+
46+
exit;
47+
}
48+
49+
/**
50+
* Emit the HTTP response to the client.
51+
*
52+
* @param ResponseInterface $response The response object to emit.
53+
*
54+
* @return void
55+
*/
56+
protected function emitResponse(ResponseInterface $response): void
57+
{
58+
do {
59+
ob_end_clean();
60+
} while (ob_get_level() > 0);
61+
62+
http_response_code($response->getStatusCode());
63+
64+
foreach ($response->getHeaders() as $name => $values) {
65+
foreach ($values as $value) {
66+
header(sprintf('%s: %s', $name, $value), false);
67+
}
68+
}
69+
header('Content-Type: application/json');
70+
71+
$body = $response->getBody();
72+
while (! $body->eof()) {
73+
echo $body->read(8192);
74+
}
75+
}
76+
}

application/forms/ChannelForm.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ipl\Validator\EmailAddressValidator;
2626
use ipl\Web\Common\CsrfCounterMeasure;
2727
use ipl\Web\Compat\CompatForm;
28+
use Ramsey\Uuid\Uuid;
2829

2930
/**
3031
* @phpstan-type ChannelOptionConfig array{
@@ -214,6 +215,7 @@ public function addChannel(): void
214215
$channel = $this->getValues();
215216
$channel['config'] = json_encode($this->filterConfig($channel['config']), JSON_FORCE_OBJECT);
216217
$channel['changed_at'] = (int) (new DateTime())->format("Uv");
218+
$channel['external_uuid'] = Uuid::uuid4()->toString();
217219

218220
$this->db->transaction(function (Connection $db) use ($channel): void {
219221
$db->insert('channel', $channel);

application/forms/ContactGroupForm.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use ipl\Web\FormDecorator\IcingaFormDecorator;
2424
use ipl\Web\FormElement\TermInput;
2525
use ipl\Web\FormElement\TermInput\Term;
26+
use Ramsey\Uuid\Uuid;
2627

2728
class ContactGroupForm extends CompatForm
2829
{
@@ -187,7 +188,15 @@ public function addGroup(): int
187188
$this->db->beginTransaction();
188189

189190
$changedAt = (int) (new DateTime())->format("Uv");
190-
$this->db->insert('contactgroup', ['name' => trim($data['group_name']), 'changed_at' => $changedAt]);
191+
192+
$this->db->insert(
193+
'contactgroup',
194+
[
195+
'name' => trim($data['group_name']),
196+
'changed_at' => $changedAt,
197+
'external_uuid' => Uuid::uuid4()->toString()
198+
]
199+
);
191200

192201
$groupIdentifier = $this->db->lastInsertId();
193202

configuration.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
$this->translate('Allow to configure contact groups')
4343
);
4444

45+
$this->providePermission(
46+
'notifications/api',
47+
$this->translate('Allow to modify configuration via API')
48+
);
49+
4550
$this->provideRestriction(
4651
'notifications/filter/objects',
4752
$this->translate('Restrict access to the objects that match the filter')

doc/20-REST-API.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# REST API
2+
3+
Icinga Notifications Web provides a REST API that allows you to manage notification-related resources programmatically.
4+
5+
With this API, you can:
6+
- Manage **contacts** and **contact groups**
7+
- Read available **notification channels**
8+
9+
This API enables easy integration with external tools, automation workflows, and configuration management systems.
10+
11+
## API Versioning
12+
13+
The API follows a **versioned** structure to ensure backward compatibility and predictable upgrades.
14+
15+
The current and first stable version is: /icingaweb2/notifications/api/v1
16+
17+
Future versions will be accessible under corresponding paths (for example, `/api/v2`), allowing you to migrate at your own pace.
18+
19+
## API Description
20+
21+
The complete API reference for version `v1` is available in [`api/v1.md`](api/v1.md).
22+
23+
It contains an OpenAPI v3.1 description with detailed information about all endpoints, including:
24+
- Request and response schemas
25+
- Example payloads
26+
- Authentication requirements
27+
- Error handling

0 commit comments

Comments
 (0)