Commit 013d4be0 authored by Sean Madsen's avatar Sean Madsen

#50 - Implement redirection for versions

For example, now
http://localhost:8080/user/en/4.7/advanced-configuration/change-logging
will redirect to:
http://localhost:8080/user/en/latest/initial-set-up/change-logging/

This example is doing two redirects -- one for the version `4.7`
redirecting to `latest` and another for the path.

Version redirects are stored in the book's yaml config in this repo.

Path redirects are store in the `redirects.txt` file within the book,
which was already working before this commit.
parent 5d9d2efe
......@@ -2,7 +2,6 @@
# http://symfony.com/doc/current/book/service_container.html
parameters:
books_dir: %kernel.root_dir%/../books
publish_path_root: %kernel.root_dir%/../web
services:
......@@ -48,6 +47,12 @@ services:
- 'publish'
- ['@streamhandler']
redirecter:
class: AppBundle\Utils\Redirecter
arguments:
- '@paths'
- '@library'
streamhandler:
class: Monolog\Handler\StreamHandler
arguments:
......@@ -56,6 +61,6 @@ services:
app.exception_listener:
class: AppBundle\EventListener\ExceptionListener
arguments:
- %publish_path_root%
- '@redirecter'
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
......@@ -2,27 +2,22 @@
namespace AppBundle\EventListener;
use AppBundle\Utils\StringTools;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use \AppBundle\Model\Library;
class ExceptionListener {
/**
* @var string Filesystem path to the directory where all published books go
*/
public $publishPathRoot;
public $redirecter;
/**
* ExceptionListener constructor.
*
* @param string $publishPathRoot
* Full filesystem path to the directory where books are to be published
* @param \AppBundle\Utils\Redirecter $redirecter
*/
public function __construct($publishPathRoot) {
$this->publishPathRoot = $publishPathRoot;
public function __construct($redirecter) {
$this->redirecter = $redirecter;
}
/**
......@@ -34,7 +29,7 @@ class ExceptionListener {
$exception = $event->getException();
if ($exception instanceof NotFoundHttpException) {
$requestUri = $event->getRequest()->getRequestUri();
$redirect = $this->lookupRedirect($requestUri);
$redirect = $this->redirecter->lookupRedirect($requestUri);
if ($redirect) {
$response = new RedirectResponse($redirect);
$event->setResponse($response);
......@@ -42,53 +37,4 @@ class ExceptionListener {
}
}
/**
* See if we have a redirect stored for the given URI. If so, return the full
* path to it as a string (which begins with a slash). If not, return NULL
*
* @param string $requestUri
* e.g. "/dev/en/latest/my-category/my-page"
*
* @return null|string
* e.g. "/dev/en/latest/foo/bar"
*/
private function lookupRedirect($requestUri) {
// Give up right away if the request contains two dots (for security)
if (strstr($requestUri, '..')) {
return NULL;
}
$requestParts = Library::parseIdentifier($requestUri);
$edition = $requestParts['editionIdentifier'];
$path = $requestParts['path'];
$fragment = $requestParts['fragment'] ? "#${requestParts['fragment']}" : '';
$redirectsFile = $this->publishPathRoot . '/' . $edition . '/redirects.txt';
// If we don't have all the info we need, then give up
if ($edition === NULL || $path === NULL || !file_exists($redirectsFile)) {
return NULL;
}
// Look for a redirect
$redirects = file($redirectsFile);
foreach ($redirects as $redirect) {
$rule = StringTools::parseRedirectRule($redirect);
if (empty($rule)) {
// Skip any rules that are invalid
break;
}
$ruleMatchesRequest = ($rule['from'] == $path);
if ($ruleMatchesRequest) {
if ($rule['type'] == 'internal') {
return "/$edition/${rule['to']}$fragment";
}
else {
return $rule['to'];
}
}
}
return NULL;
}
}
......@@ -68,11 +68,13 @@ class Library {
$rows = array();
foreach ($this->books as $book) {
foreach ($book->languages as $language) {
/* @var \AppBundle\Model\Language $language */
foreach ($language->versions as $version) {
/* @var \AppBundle\Model\Version $version */
$key = "$book->slug/$language->code/$version->branch";
$row = array(
'book' => $book->name,
'language' => $language->englishName(),
'language' => $language->getEnglishName(),
'repo' => $language->repo,
'branch' => $version->branch,
);
......@@ -152,6 +154,8 @@ class Library {
/**
* Parses an identifier into components we can use to identify a book
*
* This is the reverse of assembleIdentifier.
*
* See LibraryTest::identifierProvider() for examples
*
* @param string $identifier
......@@ -167,7 +171,6 @@ class Library {
* 'path' => 'category/foo/my-page',
* 'fragment' => 'some-section',
* ]
*
* The array will aways have the keys shown above, but values will be NULL
* if those components do not exist in the given identifier
*/
......@@ -204,4 +207,80 @@ class Library {
return $result;
}
/**
* Take several pieces of a docs URL, and combined them into a full identifier
*
* This is the reverse of parseIdentifier.
*
* If 'editionIdentifier' is present, it will trump the values in 'bookSlug',
* 'languageCode', and 'versionDescriptor'.
*
* @param array $pieces
* e.g.
* [
* 'bookSlug' => 'dev',
* 'languageCode' => 'en',
* 'versionDescriptor' => 'latest',
* 'editionIdentifier' => 'dev/en/latest',
* 'path' => 'category/foo/my-page',
* 'fragment' => 'some-section',
* ]
*
* @return string
*/
public static function assembleIdentifier($pieces) {
$result = '';
if ($pieces['editionIdentifier']) {
$result .= $pieces['editionIdentifier'];
}
else {
if ($pieces['bookSlug']) {
$result .= $pieces['bookSlug'];
}
else {
return $result;
}
if ($pieces['languageCode']) {
$result .= '/' . $pieces['languageCode'];
}
else {
return $result;
}
if ($pieces['versionDescriptor']) {
$result .= '/' . $pieces['versionDescriptor'];
}
else {
return $result;
}
}
if ($pieces['path']) {
$result .= '/' . $pieces['path'];
}
else {
return $result;
}
if ($pieces['fragment']) {
$result .= '/#' . $pieces['fragment'];
}
return $result;
}
/**
* @param string $identifier
* examples: "user/en/master", "user/en", "user", ""
*
* @return array
*/
public function getObjectsByIdentifier($identifier) {
$parts = self::parseIdentifier($identifier);
$book = $this->getBookBySlug($parts['bookSlug']);
$language = $book->getLanguageByCode($parts['languageCode']);
$version = $language->getVersionByDescriptor($parts['versionDescriptor']);
return [
'book' => $book,
'language' => $language,
'version' => $version,
];
}
}
......@@ -98,6 +98,9 @@ class Version {
// Add alias for $branch (e.g. so urls with "master" will work correctly)
$redirects[] = $this->branch;
// Add alias for $slug (e.g. so urls with "4.7" will work correctly)
$redirects[] = $this->slug;
// Make sure each alias is URL-safe
foreach ($redirects as &$redirect) {
$redirect = StringTools::urlSafe($redirect);
......
......@@ -48,7 +48,7 @@ class Publisher {
private $publishingMessages = [];
/**
* @param LoggerInterface $logger
* @param Logger $logger
* @param Filesystem $fs
* @param Library $library
* @param MkDocs $mkDocs
......@@ -181,6 +181,8 @@ class Publisher {
* @param string $versionDescriptor
* Can be the name of the version, the name of the git branch, or a name
* of a redirect defined for the version
*
* @throws \Exception
*/
private function publishVersion($book, $language, $versionDescriptor) {
$version = $this->getVersion($book, $language, $versionDescriptor);
......@@ -229,6 +231,8 @@ class Publisher {
* @param string $bookSlug
*
* @return Book
*
* @throws \Exception
*/
private function getBook($bookSlug) {
$book = $this->library->getBookBySlug($bookSlug);
......@@ -248,6 +252,8 @@ class Publisher {
* @param string $languageCode
*
* @return Language
*
* @throws \Exception
*/
private function getLanguage($book, $languageCode) {
$language = $book->getLanguageByCode($languageCode);
......@@ -272,6 +278,7 @@ class Publisher {
*
* @return Version
*
* @throws \Exception
*/
private function getVersion($book, $language, $versionDescriptor) {
$version = $language->getVersionByDescriptor($versionDescriptor);
......@@ -298,6 +305,8 @@ class Publisher {
* @param Version $version
* @param string $repoPath
* @param string $publishPath
*
* @throws \Exception
*/
private function build(
Book $book,
......
<?php
namespace AppBundle\Utils;
use \AppBundle\Model\Library;
use \AppBundle\Utils\Paths;
class Redirecter {
/**
* @var string Filesystem path to the directory where all published books go
*/
public $publishPathRoot;
/**
* @var Library $library
*/
protected $library;
/**
* @param Paths $paths
* @param Library $library
*/
public function __construct($paths, $library) {
$this->publishPathRoot = $paths->getPublishPathRoot();
$this->library = $library;
}
/**
* See if we have a redirect stored for the given URI. If so, return the full
* path to it as a string (which begins with a slash). If not, return NULL
*
* @param string $requestUri
* e.g. "/dev/en/latest/my-category/my-page"
*
* @return null|string
* e.g. "/dev/en/latest/foo/bar"
*/
public function lookupRedirect($requestUri) {
// Give up right away if the request contains two dots (for security)
if (strstr($requestUri, '..')) {
return NULL;
}
return
$this->lookupVersionRedirect($requestUri) ??
$this->lookupPageRedirect($requestUri);
}
/**
* Try to find a redirect for the page by looking to see if the version
* supplied in the request is actually one of the redirects for the versions
* defined for the book/language
*
* @param string $requestUri
*
* @return null|string
*/
private function lookupVersionRedirect($requestUri) {
$objects = $this->library->getObjectsByIdentifier($requestUri);
$requestParts = Library::parseIdentifier($requestUri);
/* @var \AppBundle\Model\Version $version */
$version = $objects['version'];
if (!$version) {
return NULL;
}
foreach ($version->redirects as $versionRedirect) {
if ($requestParts['versionDescriptor'] == $versionRedirect) {
$requestParts['versionDescriptor'] = $version->path;
$requestParts['editionIdentifier'] = NULL;
return '/' . Library::assembleIdentifier($requestParts);
}
}
return NULL;
}
/**
* Try to find a redirect for the page by looking to see if the book has
* published a `redirects.txt` file which maps the supplied path to another
* path.
*
* @param string $requestUri
*
* @return null|string
*/
private function lookupPageRedirect($requestUri) {
$requestParts = Library::parseIdentifier($requestUri);
$edition = $requestParts['editionIdentifier'];
$path = $requestParts['path'];
$redirectsFile = $this->publishPathRoot . '/' . $edition . '/redirects.txt';
// If we don't have all the info we need, then give up
if ($edition === NULL || $path === NULL || !file_exists($redirectsFile)) {
return NULL;
}
// Look for a redirect
$redirects = file($redirectsFile);
foreach ($redirects as $redirect) {
$rule = StringTools::parseRedirectRule($redirect);
if (empty($rule)) {
// Skip any rules that are invalid
break;
}
$ruleMatchesRequest = ($rule['from'] == $path);
if ($ruleMatchesRequest) {
if ($rule['type'] == 'internal') {
$requestParts['path'] = $rule['to'];
return '/' . Library::assembleIdentifier($requestParts);
}
else {
return $rule['to'];
}
}
}
return NULL;
}
}
......@@ -211,4 +211,128 @@ class LibraryTest extends \PHPUnit_Framework_TestCase {
];
}
/**
* @param array $parts
* @param string $expected
*
* @dataProvider identifierPartsProvider
*/
public function testAssembleIdentifier($parts, $expected) {
$this->assertEquals($expected, Library::assembleIdentifier($parts));
}
public function identifierPartsProvider() {
return [
[
[
'bookSlug' => NULL,
'languageCode' => NULL,
'versionDescriptor' => NULL,
'editionIdentifier' => NULL,
'path' => NULL,
'fragment' => NULL,
],
'',
],
[
[
'bookSlug' => 'dev',
'languageCode' => NULL,
'versionDescriptor' => NULL,
'editionIdentifier' => NULL,
'path' => NULL,
'fragment' => NULL,
],
'dev',
],
[
[
'bookSlug' => 'dev',
'languageCode' => 'en',
'versionDescriptor' => NULL,
'editionIdentifier' => NULL,
'path' => NULL,
'fragment' => NULL,
],
'dev/en',
],
[
[
'bookSlug' => 'dev',
'languageCode' => 'en',
'versionDescriptor' => 'latest',
'editionIdentifier' => 'dev/en/latest',
'path' => NULL,
'fragment' => NULL,
],
'dev/en/latest',
],
[
[
'bookSlug' => 'foo',
'languageCode' => 'bar',
'versionDescriptor' => 'baz',
'editionIdentifier' => 'dev/en/latest',
'path' => NULL,
'fragment' => NULL,
],
'dev/en/latest',
],
[
[
'bookSlug' => 'dev',
'languageCode' => 'en',
'versionDescriptor' => 'latest',
'editionIdentifier' => 'dev/en/latest',
'path' => 'category/foo/my-page',
'fragment' => NULL,
],
'dev/en/latest/category/foo/my-page',
],
[
[
'bookSlug' => 'dev',
'languageCode' => 'en',
'versionDescriptor' => 'latest',
'editionIdentifier' => 'dev/en/latest',
'path' => 'category/foo/my-page',
'fragment' => 'some-section',
],
'dev/en/latest/category/foo/my-page/#some-section',
],
[
[
'bookSlug' => 'dev',
'languageCode' => 'en',
'versionDescriptor' => 'latest',
'editionIdentifier' => 'dev/en/latest',
'path' => 'category/foo/my-page',
'fragment' => 'some-section#another-section',
],
'dev/en/latest/category/foo/my-page/#some-section#another-section',
],
[
[
'bookSlug' => 'dev',
'languageCode' => NULL,
'versionDescriptor' => NULL,
'editionIdentifier' => NULL,
'path' => NULL,
'fragment' => 'some-section',
],
'dev',
],
];
}
}
......@@ -633,12 +633,6 @@ class SymfonyRequirements extends RequirementCollection
'Install and enable the <strong>mbstring</strong> extension.'
);
$this->addRecommendation(
function_exists('iconv'),
'iconv() should be available',
'Install and enable the <strong>iconv</strong> extension.'
);
$this->addRecommendation(
function_exists('utf8_decode'),
'utf8_decode() should be available',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment