Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Dot\ErrorHandler\ErrorHandlerInterface;
use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware;
use Light\App\Middleware\ServiceDocLinkMiddleware;
use Mezzio\Application;
use Mezzio\Handler\NotFoundHandler;
use Mezzio\Helper\ServerUrlMiddleware;
Expand Down Expand Up @@ -41,7 +42,6 @@
// Register the routing middleware in the middleware pipeline.
// This middleware registers the Mezzio\Router\RouteResult request attribute.
$app->pipe(RouteMiddleware::class);
$app->pipe(ResponseHeaderMiddleware::class);

// The following handle routing failures for common conditions:
// - HEAD request but no routes answer that method
Expand All @@ -52,6 +52,8 @@
$app->pipe(ImplicitHeadMiddleware::class);
$app->pipe(ImplicitOptionsMiddleware::class);
$app->pipe(MethodNotAllowedMiddleware::class);
$app->pipe(ServiceDocLinkMiddleware::class);
$app->pipe(ResponseHeaderMiddleware::class);

// Seed the UrlHelper with the routing results:
$app->pipe(UrlHelperMiddleware::class);
Expand Down
3 changes: 3 additions & 0 deletions src/App/src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
use Light\App\Factory\MarkdownExtensionFactory;
use Light\App\Factory\MarkdownRuntimeLoaderFactory;
use Light\App\Factory\PackageGeneratorFactory;
use Light\App\Factory\ServiceDocLinkMiddlewareFactory;
use Light\App\Factory\SitemapGeneratorFactory;
use Light\App\Handler\GetFeedViewHandler;
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
use Light\App\Handler\GetPackagesViewHandler;
use Light\App\Handler\GetSitemapViewHandler;
use Light\App\Middleware\ServiceDocLinkMiddleware;
use Light\App\Resolver\EntityListenerResolver;
use Light\App\Service\FeedGenerator;
use Light\App\Service\GitHubClient;
Expand Down Expand Up @@ -150,6 +152,7 @@ public function getDependencies(): array
LlmsGenerator::class => LlmsGeneratorFactory::class,
MarkdownExtension::class => MarkdownExtensionFactory::class,
RuntimeLoaderInterface::class => MarkdownRuntimeLoaderFactory::class,
ServiceDocLinkMiddleware::class => ServiceDocLinkMiddlewareFactory::class,
],
'aliases' => [
EntityManager::class => 'doctrine.entity_manager.orm_default',
Expand Down
16 changes: 16 additions & 0 deletions src/App/src/Factory/ServiceDocLinkMiddlewareFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Light\App\Factory;

use Light\App\Middleware\ServiceDocLinkMiddleware;
use Psr\Container\ContainerInterface;

class ServiceDocLinkMiddlewareFactory
{
public function __invoke(ContainerInterface $container): ServiceDocLinkMiddleware
{
return new ServiceDocLinkMiddleware();
}
}
57 changes: 57 additions & 0 deletions src/App/src/Middleware/ServiceDocLinkMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Light\App\Middleware;

use Mezzio\Router\RouteResult;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

use function array_key_exists;
use function sprintf;

/**
* Adds a `Link: <...>; rel="service-doc"` header (RFC 8631) on pages that have a dedicated
* documentation page on docs.dotkernel.org, pointing at that page's own docs.
*/
class ServiceDocLinkMiddleware implements MiddlewareInterface
{
private const array ROUTE_DOCS = [
'app::index' => 'https://docs.dotkernel.org/',
'page::api' => 'https://docs.dotkernel.org/api-documentation/',
'page::admin' => 'https://docs.dotkernel.org/admin-documentation/',
'page::queue' => 'https://docs.dotkernel.org/queue-documentation/',
'page::light' => 'https://docs.dotkernel.org/light-documentation/',
'page::frontend' => 'https://docs.dotkernel.org/frontend/',
'page::wsl2' => 'https://docs.dotkernel.org/development/v2/terminal/',
'page::architecture' => 'https://docs.dotkernel.org/api-documentation/',
];

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);

$routeResult = $request->getAttribute(RouteResult::class);
if (! $routeResult instanceof RouteResult || ! $routeResult->isSuccess()) {
return $response;
}

$routeName = $routeResult->getMatchedRouteName();
if ($routeName === false || ! array_key_exists($routeName, self::ROUTE_DOCS)) {
return $response;
}

$serviceDocLink = sprintf('<%s>; rel="service-doc"', self::ROUTE_DOCS[$routeName]);
$existingLink = $response->getHeaderLine('Link');

// A single `Link` header line with comma-separated values (RFC 8288 section 3), matching
// the style the `'*'` dot_response_headers entry already uses for llms-txt/llms-full-txt.
return $response->withHeader(
'Link',
$existingLink === '' ? $serviceDocLink : $existingLink . ', ' . $serviceDocLink
);
}
}
127 changes: 127 additions & 0 deletions test/Unit/App/Middleware/ServiceDocLinkMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace LightTest\Unit\App\Middleware;

use Light\App\Middleware\ServiceDocLinkMiddleware;
use LightTest\Unit\UnitTest;
use Mezzio\Router\RouteResult;
use PHPUnit\Framework\MockObject\Exception;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ServiceDocLinkMiddlewareTest extends UnitTest
{
/**
* @throws Exception
*/
public function testAddsServiceDocLinkForRouteWithDedicatedDocs(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getHeaderLine')->with('Link')->willReturn('');
$response
->expects($this->once())
->method('withHeader')
->with('Link', '<https://docs.dotkernel.org/api-documentation/>; rel="service-doc"')
->willReturn($response);

$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$routeResult = $this->createStub(RouteResult::class);
$routeResult->method('isSuccess')->willReturn(true);
$routeResult->method('getMatchedRouteName')->willReturn('page::api');

$request = $this->createStub(ServerRequestInterface::class);
$request->method('getAttribute')->willReturn($routeResult);

$middleware = new ServiceDocLinkMiddleware();
$result = $middleware->process($request, $handler);

$this->assertSame($response, $result);
}

/**
* @throws Exception
*/
public function testCombinesWithAnExistingLinkHeaderIntoOneLine(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->method('getHeaderLine')->with('Link')->willReturn(
'</llms.txt>; rel="llms-txt", </llms-full.txt>; rel="llms-full-txt"'
);
$response
->expects($this->once())
->method('withHeader')
->with(
'Link',
'</llms.txt>; rel="llms-txt", </llms-full.txt>; rel="llms-full-txt", '
. '<https://docs.dotkernel.org/>; rel="service-doc"'
)
->willReturn($response);

$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$routeResult = $this->createStub(RouteResult::class);
$routeResult->method('isSuccess')->willReturn(true);
$routeResult->method('getMatchedRouteName')->willReturn('app::index');

$request = $this->createStub(ServerRequestInterface::class);
$request->method('getAttribute')->willReturn($routeResult);

$middleware = new ServiceDocLinkMiddleware();
$result = $middleware->process($request, $handler);

$this->assertSame($response, $result);
}

/**
* @throws Exception
*/
public function testLeavesResponseUntouchedForRouteWithNoDedicatedDocs(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->expects($this->never())->method('withHeader');

$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$routeResult = $this->createStub(RouteResult::class);
$routeResult->method('isSuccess')->willReturn(true);
$routeResult->method('getMatchedRouteName')->willReturn('page::dotboost');

$request = $this->createStub(ServerRequestInterface::class);
$request->method('getAttribute')->willReturn($routeResult);

$middleware = new ServiceDocLinkMiddleware();
$result = $middleware->process($request, $handler);

$this->assertSame($response, $result);
}

/**
* @throws Exception
*/
public function testLeavesResponseUntouchedWhenRoutingFailed(): void
{
$response = $this->createMock(ResponseInterface::class);
$response->expects($this->never())->method('withHeader');

$handler = $this->createStub(RequestHandlerInterface::class);
$handler->method('handle')->willReturn($response);

$routeResult = $this->createStub(RouteResult::class);
$routeResult->method('isSuccess')->willReturn(false);

$request = $this->createStub(ServerRequestInterface::class);
$request->method('getAttribute')->willReturn($routeResult);

$middleware = new ServiceDocLinkMiddleware();
$result = $middleware->process($request, $handler);

$this->assertSame($response, $result);
}
}