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.

@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
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.

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.

Let's discuss how we handle the openapi description and the usage of Swagger, as there are three possible solutions for me:

  • Caching (properly, not the way it's right now ;) )
  • CI (as your todo suggests)
  • Static (it's a versioned api after all)

Comment on lines +54 to +55
// TODO: works only for V1 right now
if (! class_exists($className) || ! is_subclass_of($className, ApiV1::class)) {
Copy link
Member

Choose a reason for hiding this comment

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

Then I'd suggest to check for Psr\Http\Server\RequestHandlerInterface instead, given that this is the only assumption this actions makes for this class: being able to call handle and pass it a request.

Copy link
Member

Choose a reason for hiding this comment

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

But I see that we're going in circles here. Earlier, an abstract check for reflection was used instead. Now, ApiV1 implements the interface I mentioned above. Let me propose something in addition: Do not let the base class implement the interface, the actual endpoint classes should implement it.

Json::sanitize(['message' => $e->getMessage()])
);
} catch (Throwable $e) {
$response = new Response(
Copy link
Member

Choose a reason for hiding this comment

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

Log the error:

Suggested change
$response = new Response(
Icinga\Application\Logger::error($e);
Icinga\Application\Logger::debug(IcingaException::getConfidentialTraceAsString($e));
$response = new Response(

$response = new Response(
500,
['Content-Type' => 'application/json'],
Json::sanitize(['message' => $e->getMessage()])
Copy link
Member

Choose a reason for hiding this comment

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

Do not use the exception message. Instead, An error occurred. Please check the log

$serverRequest = (new ServerRequest(
$request->getMethod(),
$request->getRequestUri(),
['Content-Type' => $request->getHeader('Content-Type')],
Copy link
Member

Choose a reason for hiding this comment

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

phpstan is on point here. It's not guaranteed at this stage that this header is part of the request, is it?

{
if (
! preg_match('/([^;]*);?/', $request->getHeader('Content-Type'), $matches)
|| $matches[1] !== 'application/json'
Copy link
Member

Choose a reason for hiding this comment

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

  • ApiV1::getValidRequestBody() throws in this case
  • This returns null, effectively preventing any handling of non-JSON

I must ask: Why does this not throw already? Or why does it check for JSON?

Comment on lines +125 to +134
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');

echo $response->getBody();
Copy link
Member

Choose a reason for hiding this comment

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

Prior to this, stop output buffering:

do {
    ob_end_clean();
} while (ob_get_level() > 0);

We're dealing with potentially large responses here and I don't want to really buffer everything twice.

Which brings me to the next suggestion: Don't cast the body to string.

You get a StreamInterface, handle it as such. Didn't look at the endpoints yet, but they have to produce streams, at least those that return large result sets (i.e. plural indexes).

Copy link
Member

Choose a reason for hiding this comment

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

Missing license header. It seems, not only this file is missing it, please make sure all PHP files have one.

Copy link
Member

Choose a reason for hiding this comment

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

The ContactForm and the ChannelForm also require this change.

* @param string $message The log message
* @param array $context Additional context variables to interpolate in the message
*/
public function log($level, $message, array $context = []): void
Copy link
Member

Choose a reason for hiding this comment

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

Icinga/icinga-php-thirdparty#47 will be merged soon, please ensure compatibility

Comment on lines +53 to +54
}
array_unshift($parsedContext, (string) $message);
Copy link
Member

Choose a reason for hiding this comment

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

empty lines after closing braces

$icingaMethod = self::MAP[$level] ?? 'debug';

$parsedContext = [];
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
Copy link
Member

Choose a reason for hiding this comment

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

Why this? The IcingaLogger handles this as well?


public function jsonEncodeResults(array $data): string
{
return Json::sanitize(['data' => (! empty($data) && ! isset($data[0])) ? [$data] : $data]);
Copy link
Member

Choose a reason for hiding this comment

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

Instead of ! isset maybe ! is_array?


$request = $request->withAttribute('httpMethod', $httpMethod);

if (! method_exists($this, $httpMethod->lowercase())) {
Copy link
Member

Choose a reason for hiding this comment

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

May I suggest to use $this->getAllowedMethods() here instead? Of course, it must return an array for this, which isn't a bad idea either, the string join is only necessary in case of the header definition and doing it then on demand seems fine to me.

*
* @param int $status The HTTP status code.
* @param array $headers An associative array of HTTP headers.
* @param ?(StreamInterface|resource|string) $body The response body.
Copy link
Member

Choose a reason for hiding this comment

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

Can't this be part of the native type?

Comment on lines +37 to +38
* @throws HttpException
* @throws HttpBadRequestException
Copy link
Member

Choose a reason for hiding this comment

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

I already wondered why ApiController catches http exceptions. Here we have a middleware responsible for validation, why isn't it also responsible for error handling? Given the fact that it throws them by itself, instead of producing error responses, it can easily be made responsible to catch errors further down the chain and to respond with fatal errors as well. Removing the need for the try…catch handler in ApiController.

* If all validations pass, it adds the validated identifier and parsed request body to the request attributes
* and passes the request to the next handler in the middleware chain.
*/
class GeneralValidatorMiddleware implements MiddlewareInterface
Copy link
Member

Choose a reason for hiding this comment

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

Just noticed, this isn't used at all. I suppose that's a result of a discussion between me and @sukhwinder33445. Please let's discuss it once more. 🙄

* Create a filter from the filter string.
*
* @param string $queryFilter
* @param array $allowedColumns
Copy link
Member

Choose a reason for hiding this comment

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

Not quite a fan of this to be honest. At the moment it's hardcoded, sure that's not much of a deal due to the versioining, but the schema isn't versioned so that's a moot point. I just wonder where this comes from. Does the OpenAPI specification make any requirements here? It surely becomes easily messy to list all available columns for larger objects. If Icinga DB Web ever has its own API like this, an error message will never ever include all available host columns! What is allowed should of course be described in the OpenAPI document, but the error message must not, IMHO. Please drop this.

@nilmerg
Copy link
Member

nilmerg commented Oct 9, 2025

Let's discuss how we handle the openapi description and the usage of Swagger, as there are three possible solutions for me:

  • Caching (properly, not the way it's right now ;) )
  • CI (as your todo suggests)
  • Static (it's a versioned api after all)

As discussed: We should serve static files that need to be prepared each time the description changes. (Read: never, as description changes mean a new version is required)

So please drop the description handling from the ApiController. The description is only available in the repository as part of the documentation. Please add doc/20-REST-API.md, give a short introduction that we have an API, use versioning and that the details are available in api/v1.md. (We will probably make use https://github.com/bharel/mkdocs-render-swagger-plugin in our docs, please refer to this example on how to embed the description.)

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