API Platform - How to Use a DTO for Posting? - symfony

I'm using API platform in my Symfony4 app to expose my resources.
It's a great framework but it force you by default to have all your Business logic in the front-end side, because it expose all your Entities and not a Business Object.
I don't like that and I prefer to have my business logic in the back-end side.
I need to create users, but there are different type of users.
So I have create a UserFactory in the back-end-side. So the front just need to push a Business object and the back-end take care of everything.
The front front can never persist a User Object directly in the DB. It is the role of the back-end
Following this tutorial to use DTO for Reading:
https://api-platform.com/docs/core/dto/#how-to-use-a-dto-for-reading
I'm trying to do the same for posting. And it works. Here is my Controller code:
/**
* #Route(
* path="/create/model",
* name="create-model",
* methods={"POST"},
* defaults={
* "_api_respond"=true,
* "_api_normalization_context"={"api_sub_level"=true},
* "_api_swagger_context"={
* "tags"={"User"},
* "summary"="Create a user Model",
* "parameters"={
*
* },
* "responses"={
* "201"={
* "description"="User Model created",
* "schema"={
* "type"="object",
* "properties"={
* "firstName"={"type"="string"},
* "lastName"={"type"="string"},
* "email"={"type"="string"},
* }
* }
* }
* }
* }
* }
* )
* #param Request $request
* #return \App\Entity\User
* #throws \App\Exception\ClassNotFoundException
* #throws \App\Exception\InvalidUserException
*/
public function createModel(Request $request)
{
$model = $this->serializer->deserialize($request->getContent(), Model::class, 'json');
$user = $this->userFactory->create($model);
$this->userRepository->save($user);
return $user;
}
It works great, but I would love my new resource to work in the Swagger UI, so I can Create via POST method new resources directly in the web interface.
For that I think I need to complete the parameter section in my _api_swagger_context. But I don't fin any documentation about that.
How can I do that?

Found the answer here: https://github.com/api-platform/docs/issues/666
You can fill parameters like this :
"parameters" = {
{
"name" = "data",
"in" = "body",
"required" = "true",
"schema" = {
"type" = "object",
"properties" = {
"firstName"={"type"="string"},
"lastName"={"type"="string"},
"email" = {"type" = "string" }
}
},
},
},
More docs about parameters for swagger here : https://swagger.io/docs/specification/2-0/describing-parameters/

Related

Security voter on relational entity field when not using custom subresource path

I have started doing some more advanced security things in our application, where companies can create their own user roles with customizable CRUD for every module, which means you can create a custom role "Users read only" where you set "read" to "2" and create, update, delete to 0 for the user module. And the same for the teams module.
0 means that he have no access at all.
1 means can access all data under company,
2 means can access only things related to him (if he is owner
of an another user),
Which should result in the behavior that, when user requests a team over a get request, it returns the team with the users that are in the team, BUT, since the user role is configured with $capabilities["users"]["read"] = 2, then team.users should contain only him, without the other team members, because user cannot see users except himself and users that he created.
So far I have managed to limit collection-get operations with a doctrine extension that implements QueryCollectionExtensionInterface and filters out what results to return to the user:
when I query with a role that has $capabilities["teams"]["read"] = 2 then the collection returns only teams that user is part of, or teams that he created.
when I query for users with role that has $capabilities["teams"]["read"] = 1 then it returns all teams inside the company. Which is correct.
The problem comes when I query a single team. For security on item operations I use Voters, which checks the user capabilities before getting/updating/inserting/... a new entity to the DB, which works fine.
So the problem is, that when the team is returned, the user list from the manytomany user<->team relation, contains all the users that are part of the team. I need to somehow filter out this to match my role capabilities. So in this case if the user has $capabilities["users"]["read"] = 2, then the team.users should contain only the user making the request, because he has access to list the teams he is in, but he has no permission to view other users than himself.
So my question is, how can add a security voter on relational fields for item-operations and collection-operations.
A rough visual representation of what I want to achieve
/**
* #ORM\ManyToMany(targetEntity="User", mappedBy="teams")
* #Groups({"team.read","form.read"})
* #Security({itemOperations={
* "get"={
* "access_control"="is_granted('user.view', object)",
* "access_control_message"="Access denied."
* },
* "put"={
* "access_control"="is_granted('user.update', object)",
* "access_control_message"="Access denied."
* },
* "delete"={
* "access_control"="is_granted('user.delete', object)",
* "access_control_message"="Access denied."
* },
* },
* collectionOperations={
* "get"={
* "access_control"="is_granted('user.list', object)",
* "access_control_message"="Access denied."
* },
* "post"={
* "access_control"="is_granted('user.create', object)",
* "access_control_message"="Access denied."
* },
* }})
*/
private $users;
I don't think Normalizer is a good solution from a performance perspective, considering that the DB query was already made.
If I understand well, in the end the only problem is that when you make a request GET /api/teams/{id}, the property $users contains all users belonging to the team, but given user's permissions, you just want to display a subset.
Indeed Doctrine Extensions are not enough because they only limits the number of entities of the targeted entity, i.e Team in your case.
But it seems that Doctrine Filters cover this use case; they allow to add extra SQL clauses to your queries, even when fetching associated entities. But I never used them myself so I can't be 100% sure. Seems to be a very low level tool.
Otherwise, I deal with a similar use case on my project, but yet I'm not sure it fit all your needs:
Adding an extra $members array property without any #ORM annotation,
Excluding the $users association property from serialization, replacing it by $members,
Decorating the data provider of the Team entity,
Making the decorated data provider fill the new property with a restricted set of users.
// src/Entity/Team.php
/**
* #ApiResource(
* ...
* )
* #ORM\Entity(repositoryClass=TeamRepository::class)
*/
class Team
{
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
*/
private $id;
/**
* #var User[]
* #ORM\ManyToMany(targetEntity=User::class) //This property is persisted but not serialized
*/
private $users;
/**
* #var User[] //This property is not persisted but serialized
* #Groups({read:team, ...})
*/
private $members = [];
// src/DataProvider/TeamDataProvider.php
class TeamDataProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
/** #var ItemDataProvider */
private $itemDataProvider;
/** #var CollectionDataProvider*/
private $collectionDataProvider;
/** #var Security */
private $security;
public function __construct(ItemDataProvider $itemDataProvider,
CollectionDataProvider $collectionDataProvider,
Security $security)
{
$this->itemDataProvider = $itemDataProvider;
$this->collectionDataProvider = $collectionDataProvider;
$this->security = $security;
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === Team::class;
}
public function getCollection(string $resourceClass, string $operationName = null)
{
/** #var Team[] $manyTeams */
$manyTeams = $this->collectionDataProvider->getCollection($resourceClass, $operationName);
foreach ($manyTeams as $team) {
$this->fillMembersDependingUserPermissions($team);
}
return $manyTeams;
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
/** #var Team|null $team */
$team = $this->itemDataProvider->getItem($resourceClass, ['id' => $id], $operationName, $context);
if ($team !== null) {
$this->fillMembersDependingUserPermissions($team);
}
return $team;
}
private function fillMembersDependingUserPermissions(Team $team): void
{
$currentUser = $this->security->getUser();
if ($currentUser->getCapabilities()['users']['read'] === 2) {
$team->setMembers([$currentUser]);
} elseif ($currentUser->getCapabilities()['users']['read'] === 1) {
$members = $team->getUsers()->getValues();
$team->setMembers($members); //Current user is already within the collection
}
}
}
EDIT AFTER REPLY
The constructor of the TeamDataProvider use concrete classes instead of interfaces because it is meant to decorate precisely ORM data providers. I just forgot that those services use aliases. You need to configure a bit:
# config/services.yaml
App\DataProvider\TeamDataProvider:
arguments:
$itemDataProvider: '#api_platform.doctrine.orm.default.item_data_provider'
$collectionDataProvider: '#api_platform.doctrine.orm.default.collection_data_provider'
This way you keep advantages of your extensions.

Symfony: datetime property string contraint violation

I am currently learning how to use the Symfony framework. The project that I'm working on is a Web API for a blog application.
Now I have created the necessary entities, provided data into it, set JWT Tokens, etc..
The next step was to automatically set an author (which is currently authorized with the token) to a written blog post. I've added some constraints and other annotations, but when I now use Postman to "POST" a new blog onto the DB it gives me the following error:
{
"title": "Latest Blog Post!",
"published": "2020-08-02 17:00:00",
"content": "This the contentof the latest blog post!",
"slug": "latest-blog-post"
}
Now, the thing is that the property "published" is of type datetime:
use Symfony\Component\Validator\Constraints as Assert;
/**
* #ORM\Entity(repositoryClass="App\Repository\BlogPostRepository")
* #ApiResource(
* itemOperations={"get"},
* collectionOperations={
* "get",
* "post"={
* "access_control"="is_granted('IS_AUTHENTICATED_FULLY')"
* }
* }
* )
*/
class BlogPost
{
/**
* #ORM\Column(type="datetime")
* #Assert\NotBlank()
* #Assert\DateTime()
*/
private $published;
public function getPublished(): ?\DateTimeInterface
{
return $this->published;
}
public function setPublished(\DateTimeInterface $published): self
{
$this->published = $published;
return $this;
}
}
What am I overlooking here?
Deleted: #Assert\DateTime() and everything worked again properly.

api-platform unwanted field update on custom operation

There is an app with api-platform on top of symfony 4. I have created a custom operation as recommended.
The Entity
* #Api\ApiResource(
* normalizationContext={"groups"={"read_importation_demand"}},
* denormalizationContext={"groups"={"write_importation_demand"}},
* itemOperations={
* "get",
* "put",
* "delete",
* "workflow"={
* "method"="POST",
* "path"="/importation_demands/{id}/workflow",
*
* "controller"=ImportationWorkflowController::class,
* }
* })
The Controller
public function __invoke(ImportationDemand $data, Request $request): ImportationDemand
{
$requestData = json_decode($request->getContent(), true);
$request->request->replace($requestData);
$workflow = $this->workflowsRegistry->get($data, 'presentacion_oferta');
$transicion = $request->get('transition');
$workflow->apply($data, constant("App\Enum\ImportationDemandWorkflowEnum::$transicion"));
$this->manager->flush();
return $data;
}
What I'm doing here is receiving a description and transition parameters to set on the workflow entity. The problem is that when I flush to database the description attribute in ImportationDemand is setted with the value of the description field received. It suposed to be setted only in the ImportationDemandWorkflow entity but is settedin both. As you can see in the code I never set the ImportationDemand description field.
Transition event listener
public function onWorkflowPresentacionOfertaEntered(WorkflowEvent $event)
{
$request = $this->container->get('request_stack')->getCurrentRequest();
$username = $this->container->get('security.token_storage')->getToken()->getUsername();
$user = $this->manager->getRepository(User::class)->loadUserByUsername($username);
/** #var ImportationDemand $solicitud */
$solicitud = $event->getSubject();
$workflow = new ImportationDemandWorkflow();
$workflow->setStatus($solicitud->getStatus())
->setDescription($request->get('description', 'No se introdujo una descripciĆ³n para esta operaciĆ³n'))
->setAction($event->getTransition()->getName())
->setDoneAt(new \DateTime())
->setPerformer($user);
$solicitud->addWorkflow($workflow);
}
The spected behavior is that the description field would be setted only in the entity ImportationDemandWorkflow.
Why is api-platform updating the description field if I'm, allegendly, taking control of the performed operation?
Thanks in advance!

set Nelmio ApiDoc return parameter description

On the ApiDoc for our controller we have specified the output response object and now we see a list of all the parameters that get returned.
How do we provide values for the version and/or description fields on this list?
I have tried adding #ApiDoc(description="text") to the response object's parameters but that doesn't seem to be doing anything.
Thanks in advance.
This is a working API method from one of my projects:
/**
* Get an extended FB token given a normal access_token
*
* #ApiDoc(
* resource=true,
* requirements={
* {
* "name"="access_token",
* "dataType"="string",
* "description"="The FB access token",
* "version" = "1.0"
* }
* },
* views = { "facebook" }
* )
* #Get("/extend/token/{access_token}", name="get_extend_fb_token", options={ "method_prefix" = false }, defaults={"_format"="json"})
*/
public function getExtendTokenAction(Request $request, $access_token)
{
//...
}
All APIDoc parameters that get returned are grouped under "requirements".
I stepped through the ApiDocBundle today and see that Description comes from the comment on the model property or method with #VirtualProperty.
For example:
/**
* This text will be displayed as the response property's description
*
* #var \DateTime
* #JMS\Type("DateTime<'Y-m-d\TH:i:sO'>")
*/
protected $dateTimeProperty;
or
/**
* VirtualProperty comment
*
* #JMS\Type("integer")
* #JMS\VirtualProperty()
* #return integer
*/
public function getVirtualProperty()
{
return $this->someFunc();
}
The same applies to the all comments on the controller method.
I haven't used nelmioApiDoc but looking at the documentation for it, using description="text" in the annotation section seems correct. Have you tried clearing you cache:
php bin/console cache:clear --env=prod
Not sure if it is related.
This section describes how versioning objects is used, and looks like you have to use #Until("x.x.x") and #Since("x.x") in your JMSSerializerBundle classes. See this link.

how to deserialize a JSON to an Entity using setters

I'm writing a Controller that accept a JSON representing a user I have to create. I'm trying to keep the controller it as light as possible and for that reason I let FOSRestBundle deserializing the JSON into the proper Entity I want to save:
/**
* #View()
*
* #ApiDoc(
* description="It creates a new user",
* section="Users"
* )
*
* #Post("/users")
* #ParamConverter("user", converter="fos_rest.request_body")
*/
public function postAction(User $user)
{
$res = $this->getHandler()->create($user);
...
and yes, everything works smoothly! Except for a caviat: I would like to encrypt the password, and User's entity does it in its setPassword method. Is there a way to do it automatically? Or should I call User::setPassword by myself in the handler I wrote?

Resources