diff --git a/.github/workflows.inactive/ci.yaml b/.github/workflows.inactive/ci.yaml new file mode 100644 index 0000000..52b91f6 --- /dev/null +++ b/.github/workflows.inactive/ci.yaml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + tests: + name: Tests + + runs-on: ${{ matrix.os }} + + env: + PHP_EXTENSIONS: json, libxml, mbstring + PHP_INI_VALUES: assert.exception=1, zend.assertions=1, pcov.directory=Classes + + strategy: + matrix: + os: + - ubuntu-latest + + php-version: + - "7.4" + + compiler: + - default + + dependencies: + - highest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Override PHP ini values for JIT compiler + if: matrix.compiler == 'jit' + run: echo "PHP_INI_VALUES=assert.exception=1, zend.assertions=1, opcache.enable=1, opcache.enable_cli=1, opcache.optimization_level=-1, opcache.jit=1205, opcache.jit_buffer_size=4096M" >> $GITHUB_ENV + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: xdebug + extensions: ${{ env.PHP_EXTENSIONS }} + ini-values: ${{ env.PHP_INI_VALUES }} + + - name: Determine composer cache directory on Linux + if: matrix.os == 'ubuntu-latest' + run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV + + - name: Determine composer cache directory on Windows + if: matrix.os == 'windows-latest' + run: echo "COMPOSER_CACHE_DIR=~\AppData\Local\Composer" >> $GITHUB_ENV + + - name: Cache dependencies installed with composer + uses: actions/cache@v1 + with: + path: ${{ env.COMPOSER_CACHE_DIR }} + key: php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}- + - name: Install lowest dependencies with composer + if: matrix.dependencies == 'lowest' + run: composer update --no-ansi --no-interaction --no-progress --prefer-lowest + + - name: Install highest dependencies with composer + if: matrix.dependencies == 'highest' + run: composer update --no-ansi --no-interaction --no-progress + + - name: Show modules + run: php -i; php -m + + - name: Run tests with phpunit + run: vendor/bin/phpunit --coverage-clover=coverage.xml + +# - name: Send code coverage report to Codecov.io +# uses: codecov/codecov-action@v1 +# with: +# token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Classes/Http/MetricsExporterComponent.php b/Classes/Http/MetricsExporterMiddleware.php similarity index 61% rename from Classes/Http/MetricsExporterComponent.php rename to Classes/Http/MetricsExporterMiddleware.php index 2a24c79..7e1a2a4 100644 --- a/Classes/Http/MetricsExporterComponent.php +++ b/Classes/Http/MetricsExporterMiddleware.php @@ -10,17 +10,20 @@ use Flownative\Prometheus\CollectorRegistry; use Flownative\Prometheus\Renderer; -use Neos\Flow\Http\Component\ComponentChain; -use Neos\Flow\Http\Component\ComponentContext; -use Neos\Flow\Http\Component\ComponentInterface; +use GuzzleHttp\Psr7\Response; use Neos\Flow\Http\ContentStream; +use Neos\Flow\Log\Utility\LogEnvironment; +use Neos\Flow\Security\Exception\AccessDeniedException; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; /** - * HTTP component which renders Prometheus metrics + * PSR-15 middleware which renders Prometheus metrics */ -class MetricsExporterComponent implements ComponentInterface +class MetricsExporterMiddleware implements MiddlewareInterface { /** * @var CollectorRegistry @@ -71,35 +74,35 @@ public function injectLogger(LoggerInterface $logger): void } /** - * @param ComponentContext $componentContext + * @param ServerRequestInterface $request + * @param RequestHandlerInterface $handler + * @return ResponseInterface + * @throws AccessDeniedException */ - public function handle(ComponentContext $componentContext): void + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if (getenv('FLOWNATIVE_PROMETHEUS_ENABLE') !== 'true') { - return; + return $handler->handle($request); } - if ($componentContext->getHttpRequest()->getUri()->getPath() !== $this->options['telemetryPath']) { - return; + if ($request->getUri()->getPath() !== $this->options['telemetryPath']) { + return $handler->handle($request); } - $componentContext->setParameter(ComponentChain::class, 'cancel', true); - - if ($this->options['basicAuth']['username'] !== '' && $this->options['basicAuth']['password'] !== '' && $this->authenticateWithBasicAuth($componentContext) === false) { - $response = $this->createResponseWithAuthenticateHeader($componentContext->getHttpResponse()); - $componentContext->replaceHttpResponse($response); - return; + if ($this->options['basicAuth']['username'] !== '' && $this->options['basicAuth']['password'] !== '') { + $authenticated = $this->authenticateWithBasicAuth($request); + if (!$authenticated) { + return $this->createResponseWithAuthenticateHeader(); + } } - $response = $this->createResponseWithRenderedMetrics($componentContext->getHttpResponse()); - $componentContext->replaceHttpResponse($response); + return $this->createResponseWithRenderedMetrics(); } /** - * @param ResponseInterface $existingResponse * @return ResponseInterface */ - private function createResponseWithRenderedMetrics(ResponseInterface $existingResponse): ResponseInterface + private function createResponseWithRenderedMetrics(): ResponseInterface { $renderer = new Renderer(); if ($this->collectorRegistry->hasCollectors()) { @@ -111,39 +114,32 @@ private function createResponseWithRenderedMetrics(ResponseInterface $existingRe $output = "# Flownative Prometheus Metrics Exporter: There are no collectors registered at the registry.\n"; } - return $existingResponse - ->withBody(ContentStream::fromContents($output)) - ->withHeader('Content-Type', 'text/plain; version=' . $renderer->getFormatVersion() . '; charset=UTF-8'); + return new Response( + 200, + ['Content-Type' => 'text/plain; version=' . $renderer->getFormatVersion() . '; charset=UTF-8'], + ContentStream::fromContents($output) + ); } /** - * @param ResponseInterface $existingResponse * @return ResponseInterface */ - private function createResponseWithAuthenticateHeader(ResponseInterface $existingResponse): ResponseInterface + private function createResponseWithAuthenticateHeader(): ResponseInterface { - return $existingResponse - ->withHeader('WWW-Authenticate', 'Basic realm="' . $this->options['basicAuth']['realm'] . '", charset="UTF-8"'); + return new Response(200, ['WWW-Authenticate' => 'Basic realm="' . $this->options['basicAuth']['realm'] . '", charset="UTF-8"']); } /** - * @param ComponentContext $componentContext + * @param ServerRequestInterface $request * @return bool + * @throws AccessDeniedException */ - private function authenticateWithBasicAuth(ComponentContext $componentContext): bool + private function authenticateWithBasicAuth(ServerRequestInterface $request): bool { - $authorizationHeaders = $componentContext->getHttpRequest()->getHeader('Authorization'); - - // For backwards-compatibility with Flow < 6.x: - if ($authorizationHeaders === null) { - $authorizationHeaders = []; - } elseif (is_string($authorizationHeaders)) { - $authorizationHeaders = [$authorizationHeaders]; - } - + $authorizationHeaders = $request->getHeader('Authorization'); if ($authorizationHeaders === []) { if ($this->logger) { - $this->logger->info('No authorization header found, asking for authentication for Prometheus telemetry endpoint'); + $this->logger->info('No authorization header found, asking for authentication for Prometheus telemetry endpoint', LogEnvironment::fromMethodName(__METHOD__)); } return false; } @@ -157,7 +153,7 @@ private function authenticateWithBasicAuth(ComponentContext $componentContext): if (!isset($authorizationHeader)) { if ($this->logger) { - $this->logger->warning('Failed authenticating for Prometheus telemetry endpoint, no "Basic" authorization header found'); + $this->logger->warning('Failed authenticating for Prometheus telemetry endpoint, no "Basic" authorization header found', LogEnvironment::fromMethodName(__METHOD__)); } return false; } @@ -169,13 +165,11 @@ private function authenticateWithBasicAuth(ComponentContext $componentContext): $givenUsername !== $this->options['basicAuth']['username'] || $givenPassword !== $this->options['basicAuth']['password'] ) { - $componentContext->replaceHttpResponse($componentContext->getHttpResponse()->withStatus(403)); if ($this->logger) { $this->logger->warning('Failed authenticating for Prometheus telemetry endpoint: wrong username or password'); } - return false; + throw new AccessDeniedException('Failed authenticating for Prometheus telemetry endpoint: wrong username or password', 1614338257); } - return true; } } diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml index ce20a67..a40978a 100644 --- a/Configuration/Objects.yaml +++ b/Configuration/Objects.yaml @@ -1,9 +1,19 @@ -Flownative\Prometheus\Http\MetricsExporterComponent: +Flownative\Prometheus\Http\MetricsExporterMiddleware: + arguments: + 1: + value: + ## Path at which metrics are published for scraping + telemetryPath: '/metrics' + + basicAuth: + username: '' + password: '' + realm: 'Flownative Prometheus Plugin' + properties: collectorRegistry: object: Flownative\Prometheus\DefaultCollectorRegistry - Flownative\Prometheus\DefaultCollectorRegistry: arguments: 1: diff --git a/Configuration/Settings.Http.yaml b/Configuration/Settings.Http.yaml index b11e5c9..acc3695 100644 --- a/Configuration/Settings.Http.yaml +++ b/Configuration/Settings.Http.yaml @@ -1,18 +1,17 @@ Neos: Flow: http: - chain: - 'process': - chain: - 'Flownative.Prometheus:metricsExporter': - 'position': 'before routing' - component: 'Flownative\Prometheus\Http\MetricsExporterComponent' - componentOptions: + middlewares: + 'Flownative.Prometheus:metricsExporter': + 'position': 'before routing' + middleware: 'Flownative\Prometheus\Http\MetricsExporterMiddleware' - ## Path at which metrics are published for scraping - telemetryPath: '/metrics' - - basicAuth: - username: '' - password: '' - realm: 'Flownative Prometheus Plugin' +Flownative: + OpenIdConnect: + Client: + middleware: + authenticationProviderName: 'Flownative.OpenIdConnect.Client:OidcProvider' + cookie: + name: 'flownative_oidc_jwt' + secure: true + sameSite: 'strict' diff --git a/README.md b/README.md index 88a6e9f..ed6f59f 100644 --- a/README.md +++ b/README.md @@ -4,40 +4,59 @@ # Prometheus client library for Neos Flow / PHP -This [Flow](https://flow.neos.io) package allows you to collect and provide metrics to [Prometheus](https://www.prometheus.io). -It supports client-side aggregation of metrics data and provides an endpoint for Prometheus for scraping these metrics. +This [Flow](https://flow.neos.io) package allows you to collect and +provide metrics to [Prometheus](https://www.prometheus.io). It supports +client-side aggregation of metrics data and provides an endpoint for +Prometheus for scraping these metrics. ## How does it work? -Your Flow application can provide different kinds of metrics, for example the current number of registered users (a gauge) or -the number of requests to your API (a counter). Metrics values are stored in a storage – currently only Redis is supported, and -there's an in-memory storage for testing. - -The metrics endpoint (by default http(s)://your-host/metrics) collects all current metric values from the storage -and renders it in a format which can be read by Prometheus. Therefore, metrics are _not_ collected or generated during a request -to the metrics endpoint. Depending on how expensive it is to update a metric (think: number of incoming HTTP requests vs. books -sold but returned throughout the last 15 years), the values may be updated on the fly (e.g. by registering a Flow HTTP Component) -or through a helper process (a cron-job or long-running command-line process). +Your Flow application can provide different kinds of metrics, for +example the current number of registered users (a gauge), or the number +of requests to your API (a counter). Metrics values are stored in a +storage – currently only Redis is supported, and there's an in-memory +storage for testing. + +The metrics endpoint (by default http(s)://your-host/metrics) collects +all current metric values from the storage and renders it in a format +which can be read by Prometheus. Therefore, metrics are _not_ collected +or generated during a request to the metrics endpoint. Depending on how +expensive it is to update a metric (think: number of incoming HTTP +requests vs. books sold but returned throughout the last 15 years), the +values may be updated on the fly (e.g. by registering a Flow HTTP +Component) or through a helper process (a cron-job or long-running +command-line process). + +## Compatibility + +The `flownative/prometheus` 0.* version branch supports Flow 5.x and +6.x, while `flownative/prometheus` 1.* supports Flow 7.0 and later. +Please note that this README applies to 1.*. [Refer to the "0" branches' +README](https://github.com/flownative/flow-prometheus/blob/0/README.md , +if you are using an earlier version of this plugin). ## Installation -The Prometheus integration is installed as a regular Flow package via Composer. For your existing project, simply include -`flownative/prometheus` into the dependencies of your Flow or Neos distribution: +The Prometheus integration is installed as a regular Flow package via +Composer. For your existing project, simply include +`flownative/prometheus` into the dependencies of your Flow or Neos +distribution: ```bash -$ composer require flownative/prometheus:0.* +$ composer require flownative/prometheus ``` -## Configuration - ### Storage -By default, the `InMemoryStorage` will be used. You will want to use the `RedisStorage` instead, so you don't loose all metrics -values between requests. The `RedisStorage` contained in this package does *not* require a special PHP extension, as it is implemented +By default, this plugin uses the `InMemoryStorage` for testing purposes. +You will want to use the `RedisStorage` instead, so you don't loose all +metrics values between requests. The `RedisStorage` contained in this +package does *not* require a special PHP extension, as it is implemented in plain PHP. -In order to use the `RedisStorage`, create an `Objects.yaml` in your package's or Flow distribution's `Configuration` directory -and add the following configuration: +In order to use the `RedisStorage`, create an `Objects.yaml` in your +package's or Flow distribution's `Configuration` directory and add the +following configuration: ```yaml Flownative\Prometheus\DefaultCollectorRegistry: @@ -55,15 +74,19 @@ Flownative\Prometheus\Storage\RedisStorage: database: 20 ``` -In this example, environment variables are used for passing access parameters to the `RedisStorage`. Test your setup by opening the -path `/metrics` of your Flow instance in a browser. You should see the following comment: +In this example, environment variables are used for passing access +parameters to the `RedisStorage`. Test your setup by opening the path +`/metrics` of your Flow instance in a browser. You should see the +following comment: ``` -# Flownative Prometheus Metrics Exporter: There are currently no metrics with data to export. +# Flownative Prometheus Metrics Exporter: There are no collectors registered at the registry. ``` -The `RedisStorage` also supports Redis cluster setups with Sentinel servers. If you'd like to connect to a cluster and use Sentinels -for autodiscovery, omit the hostname and password options and use the sentinel option instead: +The `RedisStorage` also supports Redis cluster setups with Sentinel +servers. If you'd like to connect to a cluster and use Sentinels for +autodiscovery, omit the hostname and password options and use the +sentinel option instead: ```yaml Flownative\Prometheus\Storage\RedisStorage: @@ -79,10 +102,13 @@ Flownative\Prometheus\Storage\RedisStorage: service: 'mymaster' ``` -Instead of providing sentinels as an array you can also set them as a comma-separated string. +Instead of providing sentinels as an array you can also set them as a +comma-separated string. -The `RedisStorage` can be configured to ignore connection errors. This may protect your application against fatal errors at times -when Redis is not available. Of course, no metrics are stored while Redis connections fail. +The `RedisStorage` can be configured to ignore connection errors. This +may protect your application against fatal errors at times when Redis is +not available. Of course, no metrics are stored while Redis connections +fail. ```yaml Flownative\Prometheus\Storage\RedisStorage: @@ -98,53 +124,58 @@ Flownative\Prometheus\Storage\RedisStorage: ### Telemetry Path -The path, where metrics are provided for scraping, is "/metrics" by default. You can change this path by setting a respective -option for the HTTP component: +The path, where metrics are provided for scraping, is "/metrics" by +default. You can change this path by setting a respective option for the +HTTP middleware in `Objects.yaml`: ```yaml -Neos: - Flow: - http: - chain: - 'process': - chain: - 'Flownative.Prometheus:metricsExporter': - componentOptions: - telemetryPath: '/some-other-path' +Flownative\Prometheus\Http\MetricsExporterMiddleware: + arguments: + 1: + value: + ## Path at which metrics are published for scraping + telemetryPath: '/some-other-path' ``` ### Security -By default, the telemetry endpoint is *not* active. It is active when the environment variable `FLOWNATIVE_PROMETHEUS_ENABLE` is set to "true" (ie. "true" is a string value!). -You can achieve this by setting the variable in your webserver's virtual host configuration. +By default, the telemetry endpoint is *not* active. It is active when +the environment variable `FLOWNATIVE_PROMETHEUS_ENABLE` is set to "true" +(ie. "true" is a string value!). You can achieve this by setting the +variable in your webserver's virtual host configuration. -The idea behind enabling telemetry through such a variable is, that you configure your webserver to provide metrics through a different port than your actual website or application. -This way its easy to hide metrics through firewall rules or by not providing access to that port through your load balancer. +The idea behind enabling telemetry through such a variable is, that you +configure your webserver to provide metrics through a different port +than your actual website or application. This way its easy to hide +metrics through firewall rules or by not providing access to that port +through your load balancer. -The telemetry endpoint can also be protected by requiring clients to authenticate first with username and password. HTTP Basic Authentication is configured as follows: +While setting up the telemetry endpoint on a dedicated port, it is also +possible to let it share the webserver's port. + +The telemetry endpoint can also be protected by requiring clients to +authenticate first with username and password. HTTP Basic Authentication +is configured as follows (`Objects.yaml`): ```yaml -Neos: - Flow: - http: - chain: - 'process': - chain: - 'Flownative.Prometheus:metricsExporter': - componentOptions: - basicAuth: - - # If set to non-empty values, HTTP Basic Auth is enabled: - username: 'my-username' - password: 'my-password' - - # Optional: - realm: 'Acme App Metrics' +Flownative\Prometheus\Http\MetricsExporterMiddleware: + arguments: + 1: + value: + basicAuth: + + # If set to non-empty values, HTTP Basic Auth is enabled: + username: 'my-username' + password: 'my-password' + + # Optional: + realm: 'Acme App Metrics' ``` ## Usage -The `DefaultCollectorRegistry` is pre-configured and can be injected via Dependency Injection: +The `DefaultCollectorRegistry` is pre-configured and can be injected via +Dependency Injection: ```php /** @@ -199,8 +230,9 @@ Manual usage of the Collector Registry, using the `InMemoryStorage`: ## Running the tests -All key features are backed by unit tests. Currently you need Redis running in order to run them. Provide -the necessary credentials via `REDIS_HOST`, `REDIS_PORT`, and `REDIS_PASSWORD` (see `Objects.yaml` contained -in this package). +All key features are backed by unit tests. Currently, you need Redis +running in order to run them. Provide the necessary credentials via +`REDIS_HOST`, `REDIS_PORT`, and `REDIS_PASSWORD` (see `Objects.yaml` +contained in this package). Apart from that, tests are run like any other unit test suite for Flow. diff --git a/Tests/Unit/Http/DummyRequestHandler.php b/Tests/Unit/Http/DummyRequestHandler.php new file mode 100644 index 0000000..8a6390c --- /dev/null +++ b/Tests/Unit/Http/DummyRequestHandler.php @@ -0,0 +1,40 @@ +handleCalled = true; + return new Response(200, ['X-Dummy-Request-Handler' => 'handled']); + } + + /** + * @return bool + */ + public function isHandleCalled(): bool + { + return $this->handleCalled; + } +} diff --git a/Tests/Unit/Http/MetricsExporterComponentTest.php b/Tests/Unit/Http/MetricsExporterComponentTest.php index 162fe52..36e489a 100644 --- a/Tests/Unit/Http/MetricsExporterComponentTest.php +++ b/Tests/Unit/Http/MetricsExporterComponentTest.php @@ -1,6 +1,6 @@ setParameter(ComponentChain::class, 'cancel', false); + $middleware = new MetricsExporterMiddleware(); + $middleware->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - $httpComponent = new MetricsExporterComponent(); - $httpComponent->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - $httpComponent->handle($componentContext); + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/foo')); - self::assertFalse($componentContext->getParameter(ComponentChain::class, 'cancel')); + $response = $middleware->process($request, $handler); + + self::assertTrue($handler->isHandleCalled()); + self::assertTrue($response->hasHeader('X-Dummy-Request-Handler')); } /** * @test * @throws InvalidCollectorTypeException */ - public function componentRendersMetrics(): void + public function middlewareRendersMetrics(): void { - $componentContext = new ComponentContext(new ServerRequest('GET', new Uri('http://localhost/metrics')), new Response()); - $componentContext->setParameter(ComponentChain::class, 'cancel', false); + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/metrics')); $storage = new InMemoryStorage(); $collectorRegistry = new CollectorRegistry($storage); @@ -60,70 +61,99 @@ public function componentRendersMetrics(): void $collectorRegistry->register('test_counter', Counter::TYPE, 'This is a simple counter'); $collectorRegistry->getCounter('test_counter')->inc(5); - $httpComponent = new MetricsExporterComponent(); - $httpComponent->injectCollectorRegistry($collectorRegistry); - $httpComponent->handle($componentContext); + $middleware = new MetricsExporterMiddleware(); + $middleware->injectCollectorRegistry($collectorRegistry); + + $response = $middleware->process($request, $handler); $expectedOutput = <<<'EOD' # HELP test_counter This is a simple counter # TYPE test_counter counter test_counter 5 EOD; - self::assertTrue($componentContext->getParameter(ComponentChain::class, 'cancel')); - self::assertSame($expectedOutput, $componentContext->getHttpResponse()->getBody()->getContents()); - self::assertSame('text/plain; version=0.0.4; charset=UTF-8', $componentContext->getHttpResponse()->getHeader('Content-Type')[0]); + self::assertSame($expectedOutput, $response->getBody()->getContents()); + self::assertSame('text/plain; version=0.0.4; charset=UTF-8', $response->getHeader('Content-Type')[0]); } /** * @test */ - public function componentRendersCommentIfNoMetricsExist(): void + public function middlewareRendersCommentIfNoCollectorsAreRegistered(): void { - $componentContext = new ComponentContext(new ServerRequest('GET', new Uri('http://localhost/metrics')), new Response()); + $middleware = new MetricsExporterMiddleware(); + $middleware->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - $httpComponent = new MetricsExporterComponent(); - $httpComponent->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - $httpComponent->handle($componentContext); + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/metrics')); - $expectedOutput = "# Flownative Prometheus Metrics Exporter: There are currently no metrics with data to export.\n"; - self::assertSame($expectedOutput, $componentContext->getHttpResponse()->getBody()->getContents()); + $response = $middleware->process($request, $handler); + + $expectedOutput = "# Flownative Prometheus Metrics Exporter: There are no collectors registered at the registry.\n"; + self::assertSame($expectedOutput, $response->getBody()->getContents()); } /** * @test + * @throws */ - public function telemetryPathIsConfigurable(): void + public function middlewareRendersCommentIfNoMetricsExist(): void { - $httpComponent = new MetricsExporterComponent(['telemetryPath' => '/different-metrics']); - $httpComponent->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/metrics')); + + $storage = new InMemoryStorage(); + $collectorRegistry = new CollectorRegistry($storage); + + $collectorRegistry->register('test_counter', Counter::TYPE, 'This is a simple counter'); + + $middleware = new MetricsExporterMiddleware(); + $middleware->injectCollectorRegistry($collectorRegistry); - $componentContext = new ComponentContext(new ServerRequest('GET', new Uri('http://localhost/metrics')), new Response()); - $httpComponent->handle($componentContext); - self::assertEmpty($componentContext->getHttpResponse()->getBody()->getContents()); + $response = $middleware->process($request, $handler); - $componentContext = new ComponentContext(new ServerRequest('GET', new Uri('http://localhost/different-metrics')), new Response()); - $httpComponent->handle($componentContext); - self::assertNotEmpty($componentContext->getHttpResponse()->getBody()->getContents()); + $expectedOutput = "# Flownative Prometheus Metrics Exporter: There are currently no metrics with data to export.\n"; + self::assertSame($expectedOutput, $response->getBody()->getContents()); + } + + /** + * @test + */ + public function telemetryPathIsConfigurable(): void + { + $middleware = new MetricsExporterMiddleware(['telemetryPath' => '/metrix']); + $middleware->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); + + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/metrix')); + $response = $middleware->process($request, $handler); + self::assertFalse($handler->isHandleCalled()); + self::assertNotEmpty($response->getBody()->getContents()); + + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/metrics')); + $middleware->process($request, $handler); + self::assertTrue($handler->isHandleCalled()); } /** * @test */ - public function componentRequiresHttpBasicAuthIfConfigured(): void + public function middlewareRequiresHttpBasicAuthIfConfigured(): void { - $httpComponent = new MetricsExporterComponent([ + $middleware = new MetricsExporterMiddleware([ 'basicAuth' => [ 'username' => 'prometheus', 'password' => 'password', 'realm' => '👑' ] ]); - $httpComponent->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); + $middleware->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - $componentContext = new ComponentContext(new ServerRequest('GET', new Uri('http://localhost/metrics')), new Response()); - $httpComponent->handle($componentContext); + $handler = new DummyRequestHandler(); + $request = new ServerRequest('GET', new Uri('http://localhost/metrics')); + $response = $middleware->process($request, $handler); - $authenticateHeaders = $componentContext->getHttpResponse()->getHeader('WWW-Authenticate'); + $authenticateHeaders = $response->getHeader('WWW-Authenticate'); self::assertCount(1, $authenticateHeaders); self::assertSame('Basic realm="👑", charset="UTF-8"', $authenticateHeaders[0]); } @@ -131,61 +161,58 @@ public function componentRequiresHttpBasicAuthIfConfigured(): void /** * @test */ - public function componentAcceptsCorrectHttpBasicAuthIfConfigured(): void + public function middlewareAcceptsCorrectHttpBasicAuthIfConfigured(): void { - $httpComponent = new MetricsExporterComponent([ + $middleware = new MetricsExporterMiddleware([ 'basicAuth' => [ 'username' => 'prometheus', 'password' => 'password', 'realm' => '👑' ] ]); - $httpComponent->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - - $componentContext = new ComponentContext( - new ServerRequest( - 'GET', - new Uri('http://localhost/metrics'), - [ - 'Authorization' => 'Basic ' . base64_encode('prometheus:password') - ] - ), - new Response() + $middleware->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); + + $handler = new DummyRequestHandler(); + $request = new ServerRequest( + 'GET', + new Uri('http://localhost/metrics'), + [ + 'Authorization' => 'Basic ' . base64_encode('prometheus:password') + ] ); - $httpComponent->handle($componentContext); + $response = $middleware->process($request, $handler); + $authenticateHeaders = $response->getHeader('WWW-Authenticate'); - $authenticateHeaders = $componentContext->getHttpResponse()->getHeader('WWW-Authenticate'); self::assertCount(0, $authenticateHeaders); - self::assertNotEmpty($componentContext->getHttpResponse()->getBody()->getContents()); + self::assertNotEmpty($response->getBody()->getContents()); + self::assertFalse($handler->isHandleCalled()); } /** * @test + * @throws */ - public function componentDeniesIncorrectHttpBasicAuthIfConfigured(): void + public function middlewareDeniesIncorrectHttpBasicAuthIfConfigured(): void { - $httpComponent = new MetricsExporterComponent([ + $middleware = new MetricsExporterMiddleware([ 'basicAuth' => [ 'username' => 'prometheus', 'password' => 'password', 'realm' => '👑' ] ]); - $httpComponent->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); - - $componentContext = new ComponentContext( - new ServerRequest( - 'GET', - new Uri('http://localhost/metrics'), - [ - 'Authorization' => 'Basic ' . base64_encode('prometheus:wrong-password') - ] - ), - new Response() + $middleware->injectCollectorRegistry(new CollectorRegistry(new InMemoryStorage())); + + $handler = new DummyRequestHandler(); + $request = new ServerRequest( + 'GET', + new Uri('http://localhost/metrics'), + [ + 'Authorization' => 'Basic ' . base64_encode('prometheus:wrong-password') + ] ); - $httpComponent->handle($componentContext); - self::assertSame(403, $componentContext->getHttpResponse()->getStatusCode()); - self::assertEmpty($componentContext->getHttpResponse()->getBody()->getContents()); + $this->expectExceptionCode(1614338257); + $middleware->process($request, $handler); } } diff --git a/composer.json b/composer.json index ccf46a7..feda90b 100644 --- a/composer.json +++ b/composer.json @@ -12,14 +12,21 @@ } ], "require": { - "neos/flow": "5.* || 6.* || @dev", - "php": "7.2.* || 7.3.* || 7.4.*", + "neos/flow": "7.* || @dev", + "php": "7.4.*", "ext-zlib": "*", "predis/predis": "^1.1" }, + "require-dev": { + "phpunit/phpunit": "~8.0", + "phpunit/php-code-coverage": "*" + }, "autoload": { "psr-4": { "Flownative\\Prometheus\\": "Classes" } + }, + "scripts": { + "test": "phpunit" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..de8f35a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + + + + + ./Tests/Unit/ + + + + + + ./Classes + + +