Commit e8fe2707 authored by Andrea Vos's avatar Andrea Vos
Browse files

Merge branch 'v4.0' into 'master'

v4.0

See merge request !1
parents e63360e2 6fc83f41
tests/_output/*
vendor/*
.idea/*
/.idea/*
**/.DS_Store
/vendor/*
/tests/_output/*
## Micrus Annotations ##
# Micrus Annotations
This is a module for [Micrus framework](https://micrus.avris.it) that allows you
to put some controller cofiguration straight inside the controllers.
This is a module for [Micrus framework](https://micrus.avris.it)
converts annotations into the framework config.
To install this module, open the file `app/Config/modules.yml` and add:
## Installation
- Avris\Micrus\Annotations\AnnotationsModule
Then run:
Run:
composer require avris/micrus-annotations
### @M\Route ###
Then register the module in your `App\App:registerModules`:
yield new \Avris\Micrus\Annotations\AnnotationsModule;
Generally, route definitions are put in the `app/Config/routing.yml` file, like this:
## @M\Route ###
Route definitions are put in the `config/routing.yml` file, like this:
postList: /post/list -> Post/list
postRead: /post/{int:id}/read -> Post/read
......@@ -66,18 +69,17 @@ Note that:
* when no `name` is declared, it will be set to `controller name` + `action name`, eg. `postList`,
* multiple routes can be attached to one controller.
### Secure ###
## @M\Secure
Analogously, authorisation config can also be moved from `app/Config/security.yml` to the annotations:
Analogously, authorisation config can also be moved from `config/security.yml` to the annotations:
security:
public:
- { pattern: ^/post/restricted/public$ }
restrictions:
- { pattern: ^/post/restricted }
- { pattern: ^/post/admin, roles: [ROLE_ADMIN] }
- { pattern: ^/post/add$ }
- { pattern: ^/post/(\d+)/edit$, check: canEditPost }
public:
- { pattern: ^/post/restricted/public$ }
restrictions:
- { pattern: ^/post/restricted }
- { pattern: ^/post/admin, roles: [ROLE_ADMIN] }
- { pattern: ^/post/add$ }
- { pattern: ^/post/(\d+)/edit$, check: canEditPost }
Will become:
......@@ -121,8 +123,12 @@ Will become:
// ...
}
}
## Extending
Implement `Avris\Micrus\Annotations\Loader\AnnotationsLoaderInterface` (tag `annotationsLoader`).
### Copyright ###
## Copyright
* **Author:** Andrzej Prusinowski [(Avris.it)](https://avris.it)
* **Author:** Andre Prusinowski [(Avris.it)](https://avris.it)
* **Licence:** [MIT](https://mit.avris.it)
......@@ -10,8 +10,9 @@
"homepage": "https://avris.it"
}],
"require": {
"avris/micrus": "^3.0",
"doctrine/annotations": "^1.2"
"avris/micrus": "^4.0",
"doctrine/annotations": "^1.6",
"symfony/finder": "^4.0"
},
"autoload": {
"psr-4": { "Avris\\Micrus\\Annotations\\": "src" }
......
instanceof:
Avris\Micrus\Annotations\Loader\AnnotationsLoaderInterface:
tags: ['annotationsLoader']
Avris\Micrus\Annotations\:
dir: '%MODULE_DIR%/src/'
<?php
namespace Avris\Micrus\Annotations;
namespace Avris\Micrus\Annotations\Annotation;
/**
* @Annotation
* @Target({"CLASS", "METHOD"})
*/
class Route
final class Route
{
/** @var string */
protected $pattern;
private $pattern;
/** @var string */
protected $name;
private $name;
/** @var array */
protected $options = [];
private $options = [];
public function __construct($values)
{
......@@ -29,23 +29,17 @@ class Route
}
}
/**
* @return string
*/
public function getPattern()
public function getPattern(): string
{
return $this->pattern;
}
/**
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->name;
}
public function getOptions()
public function getOptions(): array
{
return $this->options;
}
......
<?php
namespace Avris\Micrus\Annotations;
namespace Avris\Micrus\Annotations\Annotation;
/**
* @Annotation
* @Target({"CLASS", "METHOD"})
*/
class Secure
final class Secure
{
/** @var array */
protected $options = [];
private $options = [];
/** @var bool */
protected $public = false;
private $public = false;
public function __construct($values)
{
......@@ -19,21 +19,21 @@ class Secure
$this->public = true;
}
foreach (['pattern', 'roles', 'check'] as $field) {
foreach (['pattern', 'roles', 'check', 'object'] as $field) {
if (isset($values[$field])) {
$this->options[$field] = $values[$field];
}
}
}
public function getData($controller)
public function getData($target): array
{
return array_merge([
'controller' => $controller,
'target' => $target,
], $this->options);
}
public function isPublic()
public function isPublic(): bool
{
return $this->public;
}
......
<?php
namespace Avris\Micrus\Annotations;
use Avris\Micrus\Annotations\Loader\RouteAnnotationsLoader;
use Avris\Micrus\Annotations\Loader\SecureAnnotationsLoader;
use Avris\Micrus\Bootstrap\ConfigExtension;
final class AnnotationsExtension implements ConfigExtension
{
/** @var AnnotationsLoader */
private $loader;
public function __construct(AnnotationsLoader $loader)
{
$this->loader = $loader;
}
public function extendConfig(): array
{
return array_merge(
$this->loader->load(RouteAnnotationsLoader::class)->all(),
$this->loader->load(SecureAnnotationsLoader::class)->all()
);
}
}
<?php
namespace Avris\Micrus\Annotations;
use Avris\Micrus\Controller\Routing\RoutingExtension;
use Avris\Micrus\Controller\Routing\Service\RouteParser;
use Avris\Micrus\Tool\Cache\Cacher;
use Avris\Micrus\Tool\Security\SecurityExtension;
use Doctrine\Common\Annotations\AnnotationRegistry;
class AnnotationsExtensions implements RoutingExtension, SecurityExtension
{
/** @var AnnotationsLoader */
protected $loader;
public function __construct($dir, Cacher $cacher, RouteParser $routeParser)
{
AnnotationRegistry::registerFile(__DIR__ . '/Route.php');
AnnotationRegistry::registerFile(__DIR__ . '/Secure.php');
$this->loader = $cacher->cache('annotations', function () use ($dir, $routeParser) {
return new AnnotationsLoader(realpath($dir), $routeParser);
});
}
public function getRouting()
{
return $this->loader->getRoutes();
}
public function getRestrictions()
{
return $this->loader->getRestrictions();
}
public function getPublicPaths()
{
return $this->loader->getPublicPaths();
}
public function getRolesHierarchy()
{
return [];
}
}
<?php
namespace Avris\Micrus\Annotations;
use Avris\Micrus\Controller\Routing\Service\RouteParser;
use Avris\Bag\Bag;
use Avris\Micrus\Annotations\Loader\AnnotationsLoaderInterface;
use Avris\Micrus\Bootstrap\ModuleInterface;
use Avris\Micrus\Tool\Cache\CacherInterface;
use Avris\Micrus\Tool\Config\ArrayHelper;
use Doctrine\Common\Annotations\AnnotationReader;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class AnnotationsLoader
final class AnnotationsLoader
{
/** @var AnnotationReader */
protected $reader;
private $reader;
/** @var array */
protected $routes = [];
/** @var AnnotationsLoaderInterface[] */
private $loaders;
/** @var array */
protected $publicPaths = [];
/** @var ModuleInterface[] */
private $modules;
/** @var array */
protected $restrictions = [];
/** @var CacherInterface */
private $cacher;
/** @var RouteParser */
private $routeParser;
/** @var ArrayHelper */
private $arrayHelper;
public function __construct($dir, RouteParser $routeParser)
public function __construct(
AnnotationReader $reader,
array $annotationsLoaders,
array $modules,
CacherInterface $cacher,
ArrayHelper $arrayHelper
)
{
$this->reader = new AnnotationReader();
$it = new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS);
$files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST);
$this->routeParser = $routeParser;
/** @var \SplFileInfo $file */
foreach ($files as $file) {
if ($file->isFile()) {
$filename = ltrim(substr($file->getPath(), strlen($dir)) . '/' . $file->getFilename(), '/');
if ($controllerName = $this->testEnding($filename, 'Controller.php')) {
$this->loadAnnotationsForController(str_replace('/', '\\', $controllerName));
}
}
}
$this->reader = $reader;
$this->loaders = $annotationsLoaders;
$this->modules = $modules;
$this->cacher = $cacher;
$this->arrayHelper = $arrayHelper;
}
protected function loadAnnotationsForController($name)
public function load(string $loaderClass): Bag
{
$routeBase = '';
$class = new \ReflectionClass('App\Controller\\' . $name . 'Controller');
foreach ($this->reader->getClassAnnotations($class) as $ann) {
if ($ann instanceof Route) {
$routeBase = $ann->getPattern();
} elseif ($ann instanceof Secure) {
if ($ann->isPublic()) {
$this->publicPaths[] = $ann->getData($name);
} else {
$this->restrictions[] = $ann->getData($name);
}
}
}
return $this->cacher->cache($loaderClass, function () use ($loaderClass) {
$loader = $this->loaders[$loaderClass] ?? null;
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
if ($actionName = $this->testEnding($method->getName(), 'Action')) {
foreach ($this->reader->getMethodAnnotations($method) as $ann) {
$controller = $name . '/' . $actionName;
if ($ann instanceof Route) {
$routeName = $ann->getName() ?: $this->buildRouteName($controller);
$options = $ann->getOptions();
$optionsJson = $options ? ' ' . json_encode($options) : '';
$route = $this->routeParser->parse(
$routeName,
$ann->getPattern() . ' -> ' . $controller . $optionsJson,
empty($options['absolute']) ? $routeBase : ''
);
$this->routes[$routeName] = $route;
} elseif ($ann instanceof Secure) {
if ($ann->isPublic()) {
$this->publicPaths[] = $ann->getData($controller);
} else {
$this->restrictions[] = $ann->getData($controller);
}
}
}
if (!$loader) {
throw new \RuntimeException(sprintf('AnnotationLoader %s not defined', $loaderClass));
}
}
}
protected function buildRouteName($controller)
{
if ($controller === 'Home/home') {
return 'home';
}
$dir = $loader->getDir();
if (preg_match('#^(.*)Controller/(.*)#', $controller, $matches)) {
$controller = $matches[1] . '_' . $matches[2];
}
foreach ($this->modules as $module) {
$this->loadDir(
$loader,
$module->getName() . '\\' . str_replace('/', '\\', $dir),
$module->getDir() . '/src/' . str_replace('\\', '/', $dir)
);
}
return lcfirst(preg_replace_callback(
'/[\\\\\/\-][a-zA-Z]/',
function($matches) { return strtoupper($matches[0][1]); },
$controller
));
return new Bag($loader->get());
});
}
protected function testEnding($string, $ending)
public function extend(Bag $config, string $loaderClass): Bag
{
return substr($string, strlen($string) - strlen($ending)) == $ending
? substr($string, 0, strlen($string) - strlen($ending))
: false;
return new Bag(
$this->arrayHelper->merge(
$config->all(),
$this->load($loaderClass)->all()
)
);
}
/**
* @return array
*/
public function getRoutes()
private function loadDir(AnnotationsLoaderInterface $loader, string $namespace, string $dir)
{
return $this->routes;
}
if (!is_dir($dir)) {
return;
}
/**
* @return array
*/
public function getPublicPaths()
{
return $this->publicPaths;
$finder = (new Finder())
->files()
->in($dir)
->name('*.php')
->ignoreDotFiles(true)
->ignoreUnreadableDirs(true)
->ignoreVCS(true);
/** @var SplFileInfo $file */
foreach ($finder as $file) {
$this->loadClass(
$loader,
$this->buildClassName($namespace, $file->getRelativePathname())
);
}
}
/**
* @return array
*/
public function getRestrictions()
private function buildClassName(string $namespace, string $relativePathName)
{
return $this->restrictions;
return $namespace . '\\' . strtr(substr($relativePathName, 0, -4), ['/' => '\\']);
}
public function __sleep()
private function loadClass(AnnotationsLoaderInterface $loader, string $className)
{
return ['routes', 'publicPaths', 'restrictions'];
}
$class = new \ReflectionClass($className);
foreach ($this->reader->getClassAnnotations($class) as $annotation) {
$loader->loadClass($class, $annotation);
}
foreach ($class->getMethods() as $method) {
foreach ($this->reader->getMethodAnnotations($method) as $annotation) {
$loader->loadMethod($method, $annotation);
}
}
foreach ($class->getProperties() as $property) {
foreach ($this->reader->getPropertyAnnotations($property) as $annotation) {
$loader->loadProperty($property, $annotation);
}
}
}
}
<?php
namespace Avris\Micrus\Annotations;
use Avris\Micrus\Bootstrap\Module;
use Avris\Micrus\Bootstrap\ModuleInterface;
use Avris\Micrus\Bootstrap\ModuleTrait;
class AnnotationsModule implements Module
final class AnnotationsModule implements ModuleInterface
{
public function extendConfig($env, $rootDir)
{
return [
'services' => [
'annotationsExtensions' => [
'class' => AnnotationsExtensions::class,
'params' => ['{@rootDir}/app/Controller', '@cacher', '@routeParser'],
'tags' => ['routingExtension', 'securityExtension'],
],
],
];
}
use ModuleTrait;
}
<?php
namespace Avris\Micrus\Annotations\Loader;
interface AnnotationsLoaderInterface
{
public function getDir(): string;
public function loadClass(\ReflectionClass $class, $annotation);
public function loadMethod(\ReflectionMethod $method, $annotation);
public function loadProperty(\ReflectionProperty $property, $annotation);
public function get(): array;
}
<?php
namespace Avris\Micrus\Annotations\Loader;
use Avris\Micrus\Annotations\Annotation\Route;
use Avris\Micrus\Controller\Routing\Service\RouteParser;
final class RouteAnnotationsLoader implements AnnotationsLoaderInterface
{
/** @var RouteParser */
private $routeParser;
/** @var array */
private $routes = [];
/** @var string[] */
private $bases = [];
public function __construct(RouteParser $routeParser)
{
$this->routeParser = $routeParser;
}
public function getDir(): string
{
return 'Controller';
}
public function loadClass(\ReflectionClass $class, $annotation)
{
if (!$annotation instanceof Route) {
return;
}
$this->bases[$class->getName()] = $annotation->getPattern();
}
public function loadMethod(\ReflectionMethod $method, $annotation)
{
$actionName = $this->testEnding($method->getName(), 'Action');
if (!$annotation instanceof Route || !$method->isPublic() || !$actionName) {
return;
}
$className = $method->getDeclaringClass()->getName();
$target = $className . '/' . $actionName;
$this->addRoute($annotation, $target, $this->bases[$className] ?? '');
}
public function loadProperty(\ReflectionProperty $property, $annotation)
{
}
private function addRoute(Route $annotation, string $target, string $routeBase)
{
$routeName = $annotation->getName() ?: $this->buildRouteName($target);
$options = $annotation->getOptions();
$optionsJson = $options ? ' ' . json_encode($options) : '';
$this->routes[$routeName] = $this->routeParser->parse(
$routeName,
$annotation->getPattern() . ' -> ' . $target . $optionsJson,
empty($options['absolute']) ? $routeBase : ''
);
}