Skip to content

Conversation

Jan-Schuppik
Copy link

@Jan-Schuppik Jan-Schuppik commented Aug 22, 2025

@Jan-Schuppik Jan-Schuppik requested a review from nilmerg August 22, 2025 13:51
@Jan-Schuppik Jan-Schuppik self-assigned this Aug 22, 2025
@cla-bot cla-bot bot added the cla/signed CLA is signed by all contributors of a PR label Aug 22, 2025
@Jan-Schuppik Jan-Schuppik force-pushed the feature/http-api-new-approach branch from 9a3a6aa to aa2b625 Compare August 25, 2025 12:48
Copy link
Member

@nilmerg nilmerg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covers only the early parts of routing and dispatching.

run.php Outdated
Comment on lines 28 to 43
$this->addRoute('notifications/api-v1-contacts', new Zend_Controller_Router_Route_Regex(
'notifications/api/v1/contacts(?:\/(.+)|\?(.+))?',
[
'controller' => 'api-v1-contacts',
'action' => 'index',
'module' => 'notifications',
'identifier' => null
],
[
1 => 'identifier'
]
));

$this->addRoute('notifications/api-v1-contactgroups', new Zend_Controller_Router_Route_Regex(
'notifications/api/v1/contactgroups(?:\/(.+)|\?(.+))?',
[
'controller' => 'api-v1-contactgroups',
'action' => 'index',
'module' => 'notifications',
'identifier' => null
],
[
1 => 'identifier'
]
));

$this->addRoute('notifications/api-v1-channels', new Zend_Controller_Router_Route_Regex(
'notifications/api/v1/channels(?:\/(.+)|\?(.+))?',
[
'controller' => 'api-v1-channels',
'action' => 'index',
'module' => 'notifications',
'identifier' => null
],
[
1 => 'identifier'
]
));

$this->addRoute('notifications/api-v1-channels', new Zend_Controller_Router_Route_Regex(
'notifications/api(?:\/(v[0-9\.]+))(?:\/([^\/\?]+))?(?:[\/\?]([^\/\?]+))?',
[
'controller' => 'api',
'action' => 'index',
'version' => 'v1',
'module' => 'notifications',
'endpoint' => null,
'identifier' => null
],
[
1 => 'version',
2 => 'endpoint',
3 => 'identifier'
]
));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please replace this with:

Suggested change
$this->addRoute('notifications/api-v1-contacts', new Zend_Controller_Router_Route_Regex(
'notifications/api/v1/contacts(?:\/(.+)|\?(.+))?',
[
'controller' => 'api-v1-contacts',
'action' => 'index',
'module' => 'notifications',
'identifier' => null
],
[
1 => 'identifier'
]
));
$this->addRoute('notifications/api-v1-contactgroups', new Zend_Controller_Router_Route_Regex(
'notifications/api/v1/contactgroups(?:\/(.+)|\?(.+))?',
[
'controller' => 'api-v1-contactgroups',
'action' => 'index',
'module' => 'notifications',
'identifier' => null
],
[
1 => 'identifier'
]
));
$this->addRoute('notifications/api-v1-channels', new Zend_Controller_Router_Route_Regex(
'notifications/api/v1/channels(?:\/(.+)|\?(.+))?',
[
'controller' => 'api-v1-channels',
'action' => 'index',
'module' => 'notifications',
'identifier' => null
],
[
1 => 'identifier'
]
));
$this->addRoute('notifications/api-v1-channels', new Zend_Controller_Router_Route_Regex(
'notifications/api(?:\/(v[0-9\.]+))(?:\/([^\/\?]+))?(?:[\/\?]([^\/\?]+))?',
[
'controller' => 'api',
'action' => 'index',
'version' => 'v1',
'module' => 'notifications',
'endpoint' => null,
'identifier' => null
],
[
1 => 'version',
2 => 'endpoint',
3 => 'identifier'
]
));
$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'
]
));

Note that Zend processes routes in reverse order. So notifications/api-single will be processed first. It cannot match without an identifier, because there is no forward slash in our endpoint names. notifications/api-plural on the other hand cannot match with an identifier, because notifications/api-single is processed first. 😉

*/
public function indexAction(): never
{
$this->assertPermission('notifications/api/v1');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know the issues requires this. But now I'm not sure anymore, whether it makes sense to have the api version in the permission's name. Why would someone, permitted to use v1, not be able to use v2?

Though, do not change anything yet. Leave this for later.

* @throws HttpBadRequestException If the request is not valid.
* @throws SecurityException
* @throws HttpException|HttpNotFoundException
* @throws Zend_Controller_Request_Exception
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type is missing

Comment on lines 60 to 61
$version = StringHelper::cname($params['version'] ?? null, '-');
$endpoint = StringHelper::cname($params['endpoint'] ?? null, '-');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use ipl-stdlib's Str::camel here.

Comment on lines 64 to 66
if (empty($version) || empty($endpoint)) {
throw new HttpException(404, "Version and endpoint are required parameters.");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The routes I suggested should verify this and make this check obsolete.

throw new HttpException(404, "Version and endpoint are required parameters.");
}

$module = ($moduleName !== null) ? 'Module\\' . StringHelper::cname($moduleName, '-') . '\\' : '';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of our modules use dashes in their name.

}

$module = ($moduleName !== null) ? 'Module\\' . StringHelper::cname($moduleName, '-') . '\\' : '';
$className = sprintf('Icinga\\%sApi\\%s\\%s', $module, $version, $endpoint);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that this may already be compatible with an Icinga Web API 😃

$className = sprintf('Icinga\\%sApi\\%s\\%s', $module, $version, $endpoint);

// Check if the required class and method are available and valid
if (! class_exists($className) || (new ReflectionClass($className))->isAbstract()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why the abstract check is required. If an abstract class matches an endpoint, this should result in a server error, not a not found.

Comment on lines 76 to 107
// TODO: move this to an api core or version class?
$parsedMethodName = ($method === 'GET' && empty($identifier)) ? $methodName . 'Any' : $methodName;

if (! in_array($parsedMethodName, get_class_methods($className))) {
if ($method === 'GET' && in_array($methodName, get_class_methods($className))) {
$parsedMethodName = $methodName;
} else {
throw new HttpException(405, "Method $method does not exist.");
}
}

// Choose the correct constructor call based on the endpoint
if (in_array($method, ['POST', 'PUT'])) {
$data = $this->getValidatedJsonContent($request);
(new $className($request, $response))->$parsedMethodName($data);
} else {
(new $className($request, $response))->$parsedMethodName();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do this without depending on any Zend objects. Including request and response.

Once you've identified a class using the endpoint, instantiate it and interact with it by expecting it to be of type RequestHandlerInterface.

The identifier should be part of the request object in the form of an attribute (The last paragraph of the linked section).

The end result should be that this controller is the last part in the chain that is built on top of Zend routing and dispatching. Everything else is PSR7/15 compliant and can then easily migrated to an alternative in the future.

$this->version = 'v1';
$this->validateIdentifier();
$method = $this->getRequest()->getMethod();
$filterStr = Url::fromRequest()->getQueryString();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Get the query string from the request please, do not use a url object for this. Once this is PSR15 compliant, everything should be inferred from the request. Singletons are not an option.

@nilmerg
Copy link
Member

nilmerg commented Aug 26, 2025

Oh, and I got the postgres tests working in my environment. Please get back to me for this.

Jan-Schuppik and others added 17 commits September 3, 2025 12:51
ICINGA_NOTIFICATIONS_SCHEMA=/path/to/notifications/schema.sql ICINGAWEB_PATH=/icingaweb2 /usr/share/icinga-php/ipl/vendor/bin/phpunit --bootstrap test/php/bootstrap.php

Note that you will need a database for this, and propagate this via env as well:

 * Name              | Description
 * ----------------- | ------------------------
 * *_TESTDB          | The database to use
 * *_TESTDB_HOST     | The server to connect to
 * *_TESTDB_PORT     | The port to connect to
 * *_TESTDB_USER     | The user to connect with
 * *_TESTDB_PASSWORD | The password of the user
@Jan-Schuppik Jan-Schuppik force-pushed the feature/http-api-new-approach branch from 1705125 to fe816f8 Compare September 3, 2025 10:58
@Jan-Schuppik Jan-Schuppik force-pushed the feature/http-api-new-approach branch from 30b0b1b to da665ca Compare September 26, 2025 09:01
HttpMethod::POST => $this->post($identifier, $this->getValidRequestBody($request)),
HttpMethod::GET => $this->get($identifier, $filterStr),
HttpMethod::DELETE => $this->delete($identifier),
default => throw new HttpBadRequestException("Invalid request: This case shouldn't be reachable."),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this default case, please.

}
if ($httpMethod === HttpMethod::GET && ! empty($identifier) && ! empty($filterStr)) {
throw new HttpBadRequestException(
'Invalid request: ' . $httpMethod->uppercase() . ' with identifier and query parameters,'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend using sprintf() instead.

Comment on lines 158 to 159
'channel_id' => 'ch.id',
'id' => 'ch.external_uuid',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct the indentation.

* @throws HttpNotFoundException
* @throws JsonEncodeException
*/
public function get(?string $identifier, string $filterStr): array
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename the parameter to $queryFilter or $filter. The data type already specifies that it must be a string.

Comment on lines 82 to 85
HttpMethod::PUT => $this->put($identifier, $this->getValidRequestBody($request)),
HttpMethod::POST => $this->post($identifier, $this->getValidRequestBody($request)),
HttpMethod::GET => $this->get($identifier, $filterStr),
HttpMethod::DELETE => $this->delete($identifier),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct the indentation.

Comment on lines 191 to 195
'contact_id' => 'co.id',
'id' => 'co.external_uuid',
'full_name',
'username',
'default_channel' => 'ch.external_uuid',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please correct the indentation.

->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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a new line please.

$this->assertUniqueUsername($requestBody['username'], $contactId);
}

if (! $channelID = Channels::getChannelId($requestBody['default_channel'])) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please initialize the variable before the condition, as it is used outside the scope of the condition.

'contact_id' => $contactId,
'type' => $type,
'address' => $address,
'changed_at' => (int) (new DateTime())->format("Uv"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix the indentation please.

$identifier = $request->getAttribute('identifier');
$filterStr = $request->getUri()->getQuery();

$responseData = match ($request->getAttribute('httpMethod')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a good idea for the methods to return a ResponseInterface instead of an array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cla/signed CLA is signed by all contributors of a PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants