Skip to content

Commit 461ed91

Browse files
author
olaru
committed
extend Link header
1 parent c901290 commit 461ed91

5 files changed

Lines changed: 206 additions & 1 deletion

File tree

‎config/pipeline.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Dot\ErrorHandler\ErrorHandlerInterface;
66
use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware;
7+
use Light\App\Middleware\ServiceDocLinkMiddleware;
78
use Mezzio\Application;
89
use Mezzio\Handler\NotFoundHandler;
910
use Mezzio\Helper\ServerUrlMiddleware;
@@ -41,7 +42,6 @@
4142
// Register the routing middleware in the middleware pipeline.
4243
// This middleware registers the Mezzio\Router\RouteResult request attribute.
4344
$app->pipe(RouteMiddleware::class);
44-
$app->pipe(ResponseHeaderMiddleware::class);
4545

4646
// The following handle routing failures for common conditions:
4747
// - HEAD request but no routes answer that method
@@ -52,6 +52,8 @@
5252
$app->pipe(ImplicitHeadMiddleware::class);
5353
$app->pipe(ImplicitOptionsMiddleware::class);
5454
$app->pipe(MethodNotAllowedMiddleware::class);
55+
$app->pipe(ServiceDocLinkMiddleware::class);
56+
$app->pipe(ResponseHeaderMiddleware::class);
5557

5658
// Seed the UrlHelper with the routing results:
5759
$app->pipe(UrlHelperMiddleware::class);

‎src/App/src/ConfigProvider.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
use Light\App\Factory\MarkdownExtensionFactory;
2525
use Light\App\Factory\MarkdownRuntimeLoaderFactory;
2626
use Light\App\Factory\PackageGeneratorFactory;
27+
use Light\App\Factory\ServiceDocLinkMiddlewareFactory;
2728
use Light\App\Factory\SitemapGeneratorFactory;
2829
use Light\App\Handler\GetFeedViewHandler;
2930
use Light\App\Handler\GetIndexViewHandler;
3031
use Light\App\Handler\GetMarkdownArticleHandler;
3132
use Light\App\Handler\GetPackagesViewHandler;
3233
use Light\App\Handler\GetSitemapViewHandler;
34+
use Light\App\Middleware\ServiceDocLinkMiddleware;
3335
use Light\App\Resolver\EntityListenerResolver;
3436
use Light\App\Service\FeedGenerator;
3537
use Light\App\Service\GitHubClient;
@@ -150,6 +152,7 @@ public function getDependencies(): array
150152
LlmsGenerator::class => LlmsGeneratorFactory::class,
151153
MarkdownExtension::class => MarkdownExtensionFactory::class,
152154
RuntimeLoaderInterface::class => MarkdownRuntimeLoaderFactory::class,
155+
ServiceDocLinkMiddleware::class => ServiceDocLinkMiddlewareFactory::class,
153156
],
154157
'aliases' => [
155158
EntityManager::class => 'doctrine.entity_manager.orm_default',
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Factory;
6+
7+
use Light\App\Middleware\ServiceDocLinkMiddleware;
8+
use Psr\Container\ContainerInterface;
9+
10+
class ServiceDocLinkMiddlewareFactory
11+
{
12+
public function __invoke(ContainerInterface $container): ServiceDocLinkMiddleware
13+
{
14+
return new ServiceDocLinkMiddleware();
15+
}
16+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Light\App\Middleware;
6+
7+
use Mezzio\Router\RouteResult;
8+
use Psr\Http\Message\ResponseInterface;
9+
use Psr\Http\Message\ServerRequestInterface;
10+
use Psr\Http\Server\MiddlewareInterface;
11+
use Psr\Http\Server\RequestHandlerInterface;
12+
13+
use function array_key_exists;
14+
use function sprintf;
15+
16+
/**
17+
* Adds a `Link: <...>; rel="service-doc"` header (RFC 8631) on pages that have a dedicated
18+
* documentation page on docs.dotkernel.org, pointing at that page's own docs.
19+
*/
20+
class ServiceDocLinkMiddleware implements MiddlewareInterface
21+
{
22+
private const array ROUTE_DOCS = [
23+
'app::index' => 'https://docs.dotkernel.org/',
24+
'page::api' => 'https://docs.dotkernel.org/api-documentation/',
25+
'page::admin' => 'https://docs.dotkernel.org/admin-documentation/',
26+
'page::queue' => 'https://docs.dotkernel.org/queue-documentation/',
27+
'page::light' => 'https://docs.dotkernel.org/light-documentation/',
28+
'page::frontend' => 'https://docs.dotkernel.org/frontend/',
29+
'page::wsl2' => 'https://docs.dotkernel.org/development/v2/terminal/',
30+
'page::architecture' => 'https://docs.dotkernel.org/api-documentation/',
31+
];
32+
33+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
34+
{
35+
$response = $handler->handle($request);
36+
37+
$routeResult = $request->getAttribute(RouteResult::class);
38+
if (! $routeResult instanceof RouteResult || ! $routeResult->isSuccess()) {
39+
return $response;
40+
}
41+
42+
$routeName = $routeResult->getMatchedRouteName();
43+
if ($routeName === false || ! array_key_exists($routeName, self::ROUTE_DOCS)) {
44+
return $response;
45+
}
46+
47+
$serviceDocLink = sprintf('<%s>; rel="service-doc"', self::ROUTE_DOCS[$routeName]);
48+
$existingLink = $response->getHeaderLine('Link');
49+
50+
// A single `Link` header line with comma-separated values (RFC 8288 section 3), matching
51+
// the style the `'*'` dot_response_headers entry already uses for llms-txt/llms-full-txt.
52+
return $response->withHeader(
53+
'Link',
54+
$existingLink === '' ? $serviceDocLink : $existingLink . ', ' . $serviceDocLink
55+
);
56+
}
57+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LightTest\Unit\App\Middleware;
6+
7+
use Light\App\Middleware\ServiceDocLinkMiddleware;
8+
use LightTest\Unit\UnitTest;
9+
use Mezzio\Router\RouteResult;
10+
use PHPUnit\Framework\MockObject\Exception;
11+
use Psr\Http\Message\ResponseInterface;
12+
use Psr\Http\Message\ServerRequestInterface;
13+
use Psr\Http\Server\RequestHandlerInterface;
14+
15+
class ServiceDocLinkMiddlewareTest extends UnitTest
16+
{
17+
/**
18+
* @throws Exception
19+
*/
20+
public function testAddsServiceDocLinkForRouteWithDedicatedDocs(): void
21+
{
22+
$response = $this->createMock(ResponseInterface::class);
23+
$response->method('getHeaderLine')->with('Link')->willReturn('');
24+
$response
25+
->expects($this->once())
26+
->method('withHeader')
27+
->with('Link', '<https://docs.dotkernel.org/api-documentation/>; rel="service-doc"')
28+
->willReturn($response);
29+
30+
$handler = $this->createStub(RequestHandlerInterface::class);
31+
$handler->method('handle')->willReturn($response);
32+
33+
$routeResult = $this->createStub(RouteResult::class);
34+
$routeResult->method('isSuccess')->willReturn(true);
35+
$routeResult->method('getMatchedRouteName')->willReturn('page::api');
36+
37+
$request = $this->createStub(ServerRequestInterface::class);
38+
$request->method('getAttribute')->willReturn($routeResult);
39+
40+
$middleware = new ServiceDocLinkMiddleware();
41+
$result = $middleware->process($request, $handler);
42+
43+
$this->assertSame($response, $result);
44+
}
45+
46+
/**
47+
* @throws Exception
48+
*/
49+
public function testCombinesWithAnExistingLinkHeaderIntoOneLine(): void
50+
{
51+
$response = $this->createMock(ResponseInterface::class);
52+
$response->method('getHeaderLine')->with('Link')->willReturn(
53+
'</llms.txt>; rel="llms-txt", </llms-full.txt>; rel="llms-full-txt"'
54+
);
55+
$response
56+
->expects($this->once())
57+
->method('withHeader')
58+
->with(
59+
'Link',
60+
'</llms.txt>; rel="llms-txt", </llms-full.txt>; rel="llms-full-txt", '
61+
. '<https://docs.dotkernel.org/>; rel="service-doc"'
62+
)
63+
->willReturn($response);
64+
65+
$handler = $this->createStub(RequestHandlerInterface::class);
66+
$handler->method('handle')->willReturn($response);
67+
68+
$routeResult = $this->createStub(RouteResult::class);
69+
$routeResult->method('isSuccess')->willReturn(true);
70+
$routeResult->method('getMatchedRouteName')->willReturn('app::index');
71+
72+
$request = $this->createStub(ServerRequestInterface::class);
73+
$request->method('getAttribute')->willReturn($routeResult);
74+
75+
$middleware = new ServiceDocLinkMiddleware();
76+
$result = $middleware->process($request, $handler);
77+
78+
$this->assertSame($response, $result);
79+
}
80+
81+
/**
82+
* @throws Exception
83+
*/
84+
public function testLeavesResponseUntouchedForRouteWithNoDedicatedDocs(): void
85+
{
86+
$response = $this->createMock(ResponseInterface::class);
87+
$response->expects($this->never())->method('withHeader');
88+
89+
$handler = $this->createStub(RequestHandlerInterface::class);
90+
$handler->method('handle')->willReturn($response);
91+
92+
$routeResult = $this->createStub(RouteResult::class);
93+
$routeResult->method('isSuccess')->willReturn(true);
94+
$routeResult->method('getMatchedRouteName')->willReturn('page::dotboost');
95+
96+
$request = $this->createStub(ServerRequestInterface::class);
97+
$request->method('getAttribute')->willReturn($routeResult);
98+
99+
$middleware = new ServiceDocLinkMiddleware();
100+
$result = $middleware->process($request, $handler);
101+
102+
$this->assertSame($response, $result);
103+
}
104+
105+
/**
106+
* @throws Exception
107+
*/
108+
public function testLeavesResponseUntouchedWhenRoutingFailed(): void
109+
{
110+
$response = $this->createMock(ResponseInterface::class);
111+
$response->expects($this->never())->method('withHeader');
112+
113+
$handler = $this->createStub(RequestHandlerInterface::class);
114+
$handler->method('handle')->willReturn($response);
115+
116+
$routeResult = $this->createStub(RouteResult::class);
117+
$routeResult->method('isSuccess')->willReturn(false);
118+
119+
$request = $this->createStub(ServerRequestInterface::class);
120+
$request->method('getAttribute')->willReturn($routeResult);
121+
122+
$middleware = new ServiceDocLinkMiddleware();
123+
$result = $middleware->process($request, $handler);
124+
125+
$this->assertSame($response, $result);
126+
}
127+
}

0 commit comments

Comments
 (0)