Use parameter in API platform annotation - php

I am looking how to add a dynamic variable into an API Platform #ApiProperty annotation.
I found that Symfony allows that but it does not seem to work in API Platform annotations.
For example :
/**
* Redirection URL.
*
* #Groups({"authorization_code_login_write", "authorization_code_logout_write"})
* #ApiProperty(
* attributes={
* "openapi_context"={
* "type"="string",
* "example"="%app.auth.default.redirect%"
* }
* }
* )
*/
protected ?string $redirectionUrl = null;
%app.auth.default.redirect% is not replaced by the container parameter with the same name.
How should I do ?

At first sight, I see here only the one way - to create your own attribute in openapi_context, let's say my_redirect_example.
Smth like this, in example:
"openapi_context"={
"type"="string",
"my_redirect_example"=true
}
Then you need to decorate like in documentation
Smth, like that:
public function normalize($object, $format = null, array $context = [])
{
$docs = $this->decorated->normalize($object, $format, $context);
$redirectUrl = .... # your own logic to get this dynamical value
foreach ($docs['paths'] as $pathName => $path) {
foreach ($path as $operationName => $operation) {
if ($operation['my_redirect_example'] ?? false) {
$docs['paths'][$pathName][$operationName]['example'] = $redirectUrl;
}
}
}
return $docs;
}
It should work. Anyway - it is just an example (I didn't test it), just to understanding how you can handle it.
Sure, you can replace true value with your own and use it inside the if statement to get it depending on some yours own logic.

The way to go is to follow the documentation to decorate the Swagger Open API generator service (https://api-platform.com/docs/core/swagger/#overriding-the-openapi-specification).
Add your own service :
# api/config/services.yaml
services:
'App\Swagger\SwaggerDecorator':
decorates: 'api_platform.swagger.normalizer.api_gateway'
arguments: [ '#App\Swagger\SwaggerDecorator.inner' ]
autoconfigure: false
Then create you service class :
<?php
namespace App\Swagger;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Custom Swagger decorator to remove/edit some API documentation information.
*/
final class SwaggerDecorator implements NormalizerInterface
{
/**
* Decorated base Swagger normalizer.
*
* #var NormalizerInterface
*/
protected NormalizerInterface $decorated;
/**
* SwaggerDecorator constructor.
*
* #param NormalizerInterface $decorated
*/
public function __construct(NormalizerInterface $decorated)
{
$this->decorated = $decorated;
}
/**
* {#inheritDoc}
*/
public function normalize($object, string $format = null, array $context = [])
{
$docs = $this->decorated->normalize($object, $format, $context);
$docs['components']['schemas']['authorization-authorization_code_login_write']['properties']['redirectionUrl']['example'] = 'https://example.com/my-dynamic-redirection';
$docs['components']['schemas']['authorization:jsonld-authorization_code_login_write']['properties']['redirectionUrl']['example'] = 'https://example.com/my-dynamic-redirection';
return $docs;
}
/**
* {#inheritDoc}
*/
public function supportsNormalization($data, string $format = null)
{
return $this->decorated->supportsNormalization($data, $format);
}
}
You'll just have to find which keys to use, browsing the schemas can help on your Swagger UI. In my example, authorization is the short name of my API resource entity and authorization_code_login_write is the denormalization context value of the operation.
And here you go :
Of course, the ideal solution will iterate over all schemas and replace found configuration parameters with their real values. Maybe this feature could be done in API Platform itself (Follow issue : https://github.com/api-platform/api-platform/issues/1711)

Related

Laravel - Add additional information to route

Currently I am working on a project where we are trying to create a RESTful API. This API uses some default classes, for example the ResourceController, for basic behaviour that can be overwritten when needed.
Lets say we have an API resource route:
Route::apiResource('posts', 'ResourceController');
This route will make use of the ResourceController:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Repositories\ResourceRepository;
class ResourceController extends Controller
{
/**
* The resource class.
*
* #var string
*/
private $resourceClass = '\\App\\Http\\Resources\\ResourceResource';
/**
* The resource model class.
*
* #var string
*/
private $resourceModelClass;
/**
* The repository.
*
* #var \App\Repositories\ResourceRepository
*/
private $repository;
/**
* ResourceController constructor.
*
* #param \Illuminate\Http\Request $request
* #return void
*/
public function __construct(Request $request)
{
$this->resourceModelClass = $this->getResourceModelClass($request);
$this->repository = new ResourceRepository($this->resourceModelClass);
$exploded = explode('\\', $this->resourceModelClass);
$resourceModelClassName = array_last($exploded);
if (!empty($resourceModelClassName)) {
$resourceClass = '\\App\\Http\\Resources\\' . $resourceModelClassName . 'Resource';
if (class_exists($resourceClass)) {
$this->resourceClass = $resourceClass;
}
}
}
...
/**
* Store a newly created resource in storage.
*
* #param \Illuminate\Http\Request $request
* #return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$this->validate($request, $this->getResourceModelRules());
$resource = $this->repository->create($request->all());
$resource = new $this->resourceClass($resource);
return response()->json($resource);
}
/**
* Display the specified resource.
*
* #param int $id
* #return \Illuminate\Http\Response
*/
public function show($id)
{
$resource = $this->repository->show($id);
$resource = new $this->resourceClass($resource);
return response()->json($resource);
}
...
/**
* Get the model class of the specified resource.
*
* #param \Illuminate\Http\Request $request
* #return string
*/
private function getResourceModelClass(Request $request)
{
if (is_null($request->route())) return '';
$uri = $request->route()->uri;
$exploded = explode('/', $uri);
$class = str_singular($exploded[1]);
return '\\App\\Models\\' . ucfirst($class);
}
/**
* Get the model rules of the specified resource.
*
* #param \Illuminate\Http\Request $request
* #return string
*/
private function getResourceModelRules()
{
$rules = [];
if (method_exists($this->resourceModelClass, 'rules')) {
$rules = $this->resourceModelClass::rules();
}
return $rules;
}
}
As you can maybe tell we are not making use of model route binding and we make use of a repository to do our logic.
As you can also see we make use of some dirty logic, getResourceModelClass(), to determine the model class needed to perform logic on/with. This method is not really flexible and puts limits on the directory structure of the application (very nasty).
A solution could be adding some information about the model class when registrating the route. This could look like:
Route::apiResource('posts', 'ResourceController', [
'modelClass' => Post::class
]);
However it looks like this is not possible.
Does anybody have any suggestions on how to make this work or how to make our logic more clean and flexible. Flexibility and easy of use are important factors.
The nicest way would be to refactor the ResourceController into an abstract class and have a separate controller that extends it - for each resource.
I'm pretty sure that there is no way of passing some context information in routes file.
But you could bind different instances of repositories to your controller. This is generally a good practice, but relying on URL to resolve it is very hacky.
You'd have to put all the dependencies in the constructor:
public function __construct(string $modelPath, ResourceRepository $repo // ...)
{
$this->resourceModelClass = $this->modelPath;
$this->repository = $repo;
// ...
}
And do this in a service provider:
use App\Repositories\ResourceRepository;
use App\Http\Controllers\ResourceController;
// ... model imports
// ...
public function boot()
{
if (request()->path() === 'posts') {
$this->app->bind(ResourceRepository::class, function ($app) {
return new ResourceRepository(new Post);
});
$this->app->when(ResourceController::class)
->needs('$modelPath')
->give(Post::class);
} else if (request()->path() === 'somethingelse') {
// ...
}
}
This will give you more flexibility, but again, relying on pure URL paths is hacky.
I just showed an example for binding the model path and binding a Repo instance, but if you go down this road, you'll want to move all the instantiating out of the Controller constructor.
After a lot of searching and diving in the source code of Laravel I found out the getResourceAction method in the ResourceRegistrar handles the option passed to the route.
Further searching led me to this post where someone else already managed to extend this registrar en add some custom functionality.
My custom registrar looks like:
<?php
namespace App\Http\Routing;
use Illuminate\Routing\ResourceRegistrar as IlluResourceRegistrar;
class ResourceRegistrar extends IlluResourceRegistrar
{
/**
* Get the action array for a resource route.
*
* #param string $resource
* #param string $controller
* #param string $method
* #param array $options
* #return array
*/
protected function getResourceAction($resource, $controller, $method, $options)
{
$action = parent::getResourceAction($resource, $controller, $method, $options);
if (isset($options['model'])) {
$action['model'] = $options['model'];
}
return $action;
}
}
Do not forget to bind in the AppServiceProvider:
$registrar = new ResourceRegistrar($this->app['router']);
$this->app->bind('Illuminate\Routing\ResourceRegistrar', function () use ($registrar) {
return $registrar;
});
This custom registrar allows the following:
Route::apiResource('posts', 'ResourceController', [
'model' => Post::class
]);
And finally we are able to get our model class:
$resourceModelClass = $request->route()->getAction('model');
No hacky url parse logic anymore!

Twig error on WebProfiler with Doctrine filter enable

I have a strange error with Twig and the WebProfiler when I enable a Doctrine filter.
request.CRITICAL: Uncaught PHP Exception Twig_Error_Runtime: "An exception has been thrown
during the rendering of a template ("Error when rendering "http://community.localhost:8000/
_profiler/e94abf?community_subdomain=community&panel=request" (Status code is 404).")." at
/../vendor/symfony/symfony/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/
layout.html.twig line 103
This {{ render(path('_profiler_search_bar', request.query.all)) }} causes the error.
My doctrine filter allows to add filter constraint on some classes (multi tenant app with dynamic subdomains)
<?php
namespace AppBundle\Group\Community;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* Class CommunityAwareFilter
*/
class CommunityAwareFilter extends SQLFilter
{
/**
* Gets the SQL query part to add to a query.
*
* #param ClassMetadata $targetEntity
* #param string $targetTableAlias
*
* #return string The constraint SQL if there is available, empty string otherwise.
*/
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
{
if (!$targetEntity->reflClass->implementsInterface(CommunityAwareInterface::class)) {
return '';
}
return sprintf('%s.community_id = %s', $targetTableAlias, $this->getParameter('communityId')); // <-- error
// return ''; <-- no error
}
}
I have also extended Symfony Router to add subdomain placeholder automatically in routing.
Do you have any idea what can cause this ?
UPDATE
<?php
namespace AppBundle\Routing;
use AppBundle\Group\Community\CommunityResolver;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router as BaseRouter;
class Router implements RouterInterface
{
/**
* #var BaseRouter
*/
private $router;
/**
* #var RequestStack
*/
private $request;
/**
* #var CommunityResolver
*/
private $communityResolver;
/**
* Router constructor.
*
* #param BaseRouter $router
* #param RequestStack $request
* #param CommunityResolver $communityResolver
*/
public function __construct(BaseRouter $router, RequestStack $request, CommunityResolver $communityResolver)
{
$this->router = $router;
$this->request = $request;
$this->communityResolver = $communityResolver;
}
/**
* Sets the request context.
*
* #param RequestContext $context The context
*/
public function setContext(RequestContext $context)
{
$this->router->setContext($context);
}
/**
* Gets the request context.
*
* #return RequestContext The context
*/
public function getContext()
{
return $this->router->getContext();
}
/**
* Gets the RouteCollection instance associated with this Router.
*
* #return RouteCollection A RouteCollection instance
*/
public function getRouteCollection()
{
return $this->router->getRouteCollection();
}
/**
* Tries to match a URL path with a set of routes.
*
* If the matcher can not find information, it must throw one of the exceptions documented
* below.
*
* #param string $pathinfo The path info to be parsed (raw format, i.e. not urldecoded)
*
* #return array An array of parameters
*
* #throws ResourceNotFoundException If the resource could not be found
* #throws MethodNotAllowedException If the resource was found but the request method is not allowed
*/
public function match($pathinfo)
{
return $this->router->match($pathinfo);
}
public function generate($name, $parameters = array(), $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH)
{
if (null !== ($community = $this->communityResolver->getCommunity())) {
$parameters['community_subdomain'] = $community->getSubDomain();
}
return $this->router->generate($name, $parameters, $referenceType);
}
}
I found the solution, in fact I passed my "tenant" (here my "community") object in the Session like this (in a subscriber onKernelRequest)
if (null === ($session = $request->getSession())) {
$session = new Session();
$session->start();
$request->setSession($session);
}
$session->set('community', $community);
I changed to store this object in a service and it works. Maybe using the Session to store data is a bad practice.
I think your Symmfony Router override may cause the problem. Can you paste us the code ?

Replacing the Translator service in Symfony 3

In my Symfony 2.8 project I have an extension that adds some extra logic to the trans method:
parameters:
translator.class: MyBundle\Twig\TranslationExtension
The class looks like this:
namespace MyBundle\Twig\TranslationExtension;
use Symfony\Bundle\FrameworkBundle\Translation\Translator as BaseTranslator;
class TranslationExtension extends BaseTranslator
{
private $currentLocale;
public function trans($id, array $parameters = array(), $domain = null, $locale = null)
{
$translation = parent::trans($id, $parameters, $domain, $locale);
// Some extra logic here
return $translation;
}
public function transChoice($id, $number, array $parameters = array(), $domain = null, $locale = null)
{
return parent::transChoice($id, $number, $parameters, $domain, $locale);
}
}
Now, I'm migrating to Symfony 3, where those class parameters are deprecated, but how can I implement this by overwriting the translator service?
Instead of extending, it would be better to decorate the translator service. Right now you overriding the class name, which will also override other bundles that want to decorate the service. And I see you made it an extension because of Twig, the original Twig {{ trans() }} filter will use the decorated service too.
services:
app.decorating_translator:
class: AppBundle\DecoratingTranslator
decorates: translator
arguments: ['#app.decorating_translator.inner'] # original translator
public: false
See documentation about decorating here: http://symfony.com/doc/current/service_container/service_decoration.html
Here is full working example how to decorate translator service in symfony 3 and replace parameter in all translated strings.
Decorate service in config:
# app/config/services.yml
app.decorating_translator:
class: AppBundle\Translation\Translator
decorates: translator
arguments:
- '#app.decorating_translator.inner'
# passing custom parameters
- {'%%app_name%%': '%app_name%', '%%PRETTY_ERROR%%': 'This is not nice:'}
public: false
Create new translator that reuses original translator and adds parameters defined in service config. The only new code is updateParameters() method and call to it:
# AppBundle/Translation/Translator.php
namespace AppBundle\Translation;
use Symfony\Component\Translation\TranslatorBagInterface;
use Symfony\Component\Translation\TranslatorInterface;
class Translator implements TranslatorInterface, TranslatorBagInterface
{
/** #var TranslatorBagInterface|TranslatorInterface */
protected $translator;
/** #var array */
private $parameters;
/**
* #param TranslatorInterface|TranslatorBagInterface $translator
* #param array $parameters
*/
public function __construct($translator, $parameters)
{
$this->translator = $translator;
$this->parameters = $parameters;
}
/**
* #param string $id
* #param array $parameters
* #param null $domain
* #param null $locale
*
* #return string
*/
public function trans($id, array $parameters = [], $domain = null, $locale = null)
{
$parameters = $this->updateParameters($parameters);
return $this->translator->trans($id, $parameters, $domain, $locale);
}
/**
* #param string $id
* #param int $number
* #param array $parameters
* #param null $domain
* #param null $locale
*
* #return string
*/
public function transChoice($id, $number, array $parameters = [], $domain = null, $locale = null)
{
$parameters = $this->updateParameters($parameters);
return $this->translator->transChoice($id, $number, $parameters, $domain, $locale);
}
/**
* #param string $locale
*/
public function setLocale($locale)
{
$this->translator->setLocale($locale);
}
/**
* #return string
*/
public function getLocale()
{
return $this->translator->getLocale();
}
/**
* #param string|null $locale
*
* #return \Symfony\Component\Translation\MessageCatalogueInterface
*/
public function getCatalogue($locale = null)
{
return $this->translator->getCatalogue($locale);
}
/**
* #param array $parameters
*
* #return array
*/
protected function updateParameters($parameters)
{
return array_merge($this->parameters, $parameters);
}
}
Now every time you translate message %app_config% will replaced with parameter from config (e.g. parameters.yml) and %PRETTY_ERROR% will be replace with static string.
If needed it is possible to override same parameters when calling trans:
{{ 'layout.title.home'|trans({'%app_name%': 'Real App No. 1'}) }}
Read about service decoration here
#Aurelijus Rozenas:
add this in your Translator:
public function __call($method, $args)
{
return \call_user_func_array(array($this->translator, $method), $args);
}

FOSRestBundle: partial response in function of attributes asked in the request

Context
I found a lot of questions about partial API response with FOSRest and all the answers are based on the JMS serializer options (exlude, include, groups, etc). It works fine but I try to achieve something less "static".
Let's say I have a user with the following attributes: id username firstname lastname age sex
I retrieve this user with the endpoint GET /users/{id} and the following method:
/**
* #View
*
* GET /users/{id}
* #param integer $user (uses ParamConverter)
*/
public function getUserAction(User $user) {
return $user;
}
The method returns the user with all his attributes.
Now I want to allow something like that: GET /users/{id}?attributes=id,username,sex
Question
Did I missed a functionality of FOSRestBUndle, JMSserializer or SensioFrameworkExtraBundle to achieve it automatically? An annotation, a method, a keyword in the request or something else?
Otherwise, what is the best way to achieve it?
Code
I thought to do something like that:
/**
* #View
* #QueryParam(name="attributes")
*
* GET /users/{id}
*
* #param integer $user (uses ParamConverter)
*/
public function getUserAction(User $user, $attributes) {
$groups = $attributes ? explode(",", $attributes) : array("Default");
$view = $this->view($user, 200)
->setSerializationContext(SerializationContext::create()->setGroups($groups));
return $this->handleView($view);
}
And create a group for each attribute:
use JMS\Serializer\Annotation\Groups;
class User {
/** #Groups({"id"}) */
protected $id;
/** #Groups({"username"}) */
protected $username;
/** #Groups({"firstname"}) */
protected $firstname;
//etc
}
My implementation based on Igor's answer:
ExlusionStrategy:
use JMS\Serializer\Exclusion\ExclusionStrategyInterface;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\Metadata\PropertyMetadata;
use JMS\Serializer\Context;
class FieldsExclusionStrategy implements ExclusionStrategyInterface {
private $fields = array();
public function __construct(array $fields) {
$this->fields = $fields;
}
public function shouldSkipClass(ClassMetadata $metadata, Context $navigatorContext) {
return false;
}
public function shouldSkipProperty(PropertyMetadata $property, Context $navigatorContext) {
if (empty($this->fields)) {
return false;
}
if (in_array($property->name, $this->fields)) {
return false;
}
return true;
}
}
Controller:
/**
* #View
* #QueryParam(name="fields")
*
* GET /users/{id}
*
* #param integer $user (uses ParamConverter)
*/
public function getUserAction(User $user, $fields) {
$context = new SerializationContext();
$context->addExclusionStrategy(new FieldsExclusionStrategy($fields ? explode(',', $fields) : array()));
return $this->handleView($this->view($user)->setSerializationContext($context));
}
Endpoint:
GET /users/{id}?fields=id,username,sex
You can do it like that through groups, as you've shown. Maybe a bit more elegant solution would be to implement your own ExclusionStrategy. #Groups and other are implementations of ExclusionStrategyInterface too.
So, say you called your strategy SelectFieldsStrategy. Once you implement it, you can add it to your serialization context very easy:
$context = new SerializationContext();
$context->addExclusionStrategy(new SelectFieldsStrategy(['id', 'name', 'someotherfield']));

Symofny2 Get a listing of available Logical Controller Names

I need to show a choice with a list of all available controllers as Logical Controller Names AcmeBundle:ControllerName:ActionName
I see that CLI command php app/console router:debug dumps a similar listing, but with controller names, e.g. fos_user_security_login.
How can I ask Symfony for their Logical Controller Name representation?
Thanks!
As #hous said, this post was useful, but incomplete and its accepted answer misleading.
A) Getting the controllers
With this code I get all controllers, but with their FQCN::method or service:method notation.
// in a Controller.
$this->container->get('router')->getRouteCollection()->all()
Some Background
The previous method will return a big array of routes. Follows one key => value:
'admin_chacra' => // route name
object(Symfony\Component\Routing\Route)[1313]
...
private 'defaults' =>
array (size=1)
'_controller' => string 'Application\ColonizacionBundle\Controller\ChacraController::indexAction' (length=71)
The FQCN::method notation is the right argument to the build method of ControllerNameParser::build(). The service notation is not parsed, as it gets handled by the following code in ControllerResolver::createController()`
$count = substr_count($controller, ':');
if (2 == $count) {
// controller in the a:b:c notation then
/* #var $this->parser ControllerNameParser parse() is the oposite of build()*/
$controller = $this->parser->parse($controller);
} elseif (1 == $count) {
// controller in the service:method notation
list($service, $method) = explode(':', $controller, 2);
return array($this->container->get($service), $method);
} else {
throw new \LogicException(sprintf('Unable to parse the controller name "%s".', $controller));
}
B) Generating Logical Controller Names
So all I have to do is filter out the controllers I don't want, {FOS; framework's; etc} and feed build() with each selected one. E.g. by selecting only the _controller attributes that matches my bundles namespace Application\*Bundle in my case.
Here's the build docBlock
/**
* Converts a class::method notation to a short one (a:b:c).
*
* #param string $controller A string in the class::method notation
*
* #return string A short notation controller (a:b:c)
*
* #throws \InvalidArgumentException when the controller is not valid or cannot be found in any bundle
*/
My Implementation
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser;
class ActivityRoleControllerType extends AbstractType
{
...
/**
* Controller choices
*
* #var array
*/
private static $controllers = array();
/**
* Controller Name Parser
*
* #var ControllerNameParser
*/
private $parser;
/**
* expects the service_container service
*/
public function __construct(ContainerInterface $container)
{
$this->parser = new ControllerNameParser($container->get('kernel'));
self::$controllers = $this->getControllerLogicalNames(
$container->get('router')->getRouteCollection()->all(), $this->parser
);
}
/**
* Creates Logical Controller Names for all controllers under \Application\*
* namespace.
*
* #param Route[] $routes The routes to iterate through.
* #param ControllerNameParser $parser The Controller Name parser.
*
* #return array the ChoiceType choices compatible array of Logical Controller Names.
*/
public function getControllerLogicalNames(array $routes, ControllerNameParser $parser)
{
if (! empty(self::$controllers)) {
return self::$controllers;
}
$controllers = array();
/* #var $route \Symfony\Component\Routing\Route */
foreach ($routes as $route) {
$controller = $route->getDefault('_controller')
if (0 === strpos($controller, 'Application\\')) {
try {
$logicalName = $parser->build($controller);
$controllers[$logicalName] = $logicalName;
} catch (\InvalidArgumentException $exc) {
// Do nothing, invalid names skiped
continue;
}
}
}
asort($controllers);
return $controllers;
}
}

Categories