So far we've been struggling with Symfony, Doctrine, and Serializer depth.
I'd like to be able to provide just one-level-depth JSON REST API with Symfony, allowing me to manage my "foreign key" and relation logic directly from the view.
GET /people/1
{
id:1,
name:"theonewhoknocks",
friends: [3, 12, 25]
}
Using FosRESTBundle, we've been strugling at succeeding on that. (we've seen "depth" anotations and "groups" views for models, but none of this fit our need).
The question is simple, before we make a choice for our future API, we have to know:
is api-platform able to provide a dead simple one level (with apparent foreign keys) REST API ?
API Platform can handle that using the Serializer Symfony bundle and its annotation set.
To define what will be returned by an operation we use a normalizationContext which define group(s) of property to include in result of an api operation. Property to include have then this group name linked to #Groups serializer annotation
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* #ORM\Entity()
* #ApiResource(normalizationContext={"groups"={"read"}}
*/
class Book {
/**
* #ORM\Column()
* #Groups({"read"})
*/
private $title;
/**
* #ORM\ManyToOne(targetEntity="User", inversedBy="books")
* #Groups({"read"})
*/
private $author;
/**
* Will not be included in result
*/
private $secret_comment;
}
If a relation column is in a Group as $author here, properties defined in a group in the child class will be included in the result
/**
* #ORM\Entity()
* #ApiResource(normalizationContext={"groups"={"read"}})
*/
class User {
/**
* #ORM\Column()
* #Groups({"read"})
*/
private $username;
}
In order to avoid cyclic recursion you can specify the max depth of child relation joins with annotation #MaxDepth(n) where n is the max depth (1 in your case). This annotation has to be enabled with enable_max_depth property in serializer context of the #ApiResource annotation
/**
* #ApiPlatform(normalizationContext={"groups"={"read"}, "enable_max_depth"=true})
*/
class Book {
/**
* #ORM\ManyToOne(targetEntity="User", inversedBy="books")
* #Groups({"read"})
* #MaxDepth(1)
*/
private $author;
}
Please note that API Platform is in this case an agregation of existing bundles and features. Refer to the main bundles for detailed informations (here the Symfony Serializer bundle)
Be aware that the MaxDepth on the symfony serializer might not behave as expected, see https://github.com/symfony/symfony/issues/33466
The annotation basically says "from here on, render max N instances of THE SAME CLASS into the graph"
So given a pseudo structure like
Class A:
#MaxDepth(1)
Class B:
Class C:
Class D:
would render the whole thing A.B.C.D, while
Class A:
#MaxDepth(1)
Class B:
Class B:
Class B:
would only render A.B.B
Which is quite different from what e.g. JMS serializer is doing, where MaxDepth really means "From here on, max N steps into the relation graph".
Bad thing is that JMS serializer is not supported by api-platform: https://github.com/api-platform/api-platform/issues/753
So the answer to your question, atm, is: no. :/
As #leberknecht indicated, you might not get the results you are looking for using MaxDepth.
This script:
#[ApiResource(
normalizationContext: ['enable_max_depth'=>true],
)]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180)]
private ?string $someProperty = null;
#[ORM\ManyToOne(targetEntity: UserClass::class)]
#[SymfonyMaxDepth(1)]
private ?User $createdBy = null;
}
will return:
{
"id": 123,
"someProperty": "objectProperty",
"createdBy": {
"id": 20,
"someProperty": "parentProperty",
"createdBy": "users/5"
}
}
and this script:
#[ApiResource]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180)]
private ?string $someProperty = null;
#[ORM\ManyToOne(targetEntity: UserClass::class)]
#[ApiProperty(readableLink: false, writableLink: false)]
private ?User $createdBy = null;
}
will return
{
"id": 123,
"someProperty": "objectProperty",
"createdBy": "users/20"
}
Related
I’m trying to order my embed documents.
The field looks like this
/**
* #ODM\EmbedMany(targetDocument=Image::class, strategy="set")
* #ODM\Index(keys={"order"="asc"})
* #Groups({"offer:read"})
*/
protected $images = [];
The Image EmbeddedDocument
namespace App\Document\Embedded;
use App\Document\Traits\NameableTrait;
use App\Document\Traits\OrderableTrait;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
/**
* #ODM\EmbeddedDocument
*/
class Image
{
use NameableTrait;
use OrderableTrait;
…
}
And the orderable trait
namespace App\Document\Traits;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
use Symfony\Component\Serializer\Annotation\Groups;
trait OrderableTrait
{
/**
* #ODM\Field(type="int")
* #Groups({"offer:read"})
*
* #var int|null
*/
private $order;
public function getOrder(): int
{
return $this->order;
}
public function setOrder(int $order): void
{
$this->order = $order;
}
}
I updated the indexes with bin/console doctrine:mongodb:schema:update
However my Images are not ordered. Are the indexes the way to do it?
Index is not used for ordering documents in any way, they are telling the database to index the data so it can be searched through efficiently. In Doctrine's ORM there is an #OrderBy annotation but sadly it has not made its way to the ODM (yet). The solution would either be to support such thing natively in the ODM itself or you could use a custom collection for your embedded documents.
Here you will find documentation for custom collections and here's a link to my package that aims to kickstart your own implementations: https://github.com/malarzm/collections. To get what you need you will need your own collection class looking somehow like this:
class SortableCollection extends Malarzm\Collections\SortedCollection
{
public function compare($a, $b)
{
return $a->getOrder() <=> $b->getOrder();
}
}
and then plug it into your mapping:
/**
* #ODM\EmbedMany(targetDocument=Image::class, strategy="set", customCollection=SortableCollection::class)
* #ODM\Index(keys={"order"="asc"})
* #Groups({"offer:read"})
*/
protected $images = [];
New to Laravel and Backpack here, but trying to integrate the PermissionManager with Backpack. I've got it all installed and showing the Users/Permissions/Roles in the UI, however I was unable to figure out how to show/hide buttons and functionality in the Backpack UI based on those permissions. I'm hoping someone can comment on the solution I came up with or if there is something else that should be used.
Note, this is really about showing and hiding UI elements, not the actual policies (which I am handling separately using the "can" functions in my controllers, routes, etc.)
My solution:
In my EntityCrudController, I use a trait I made called CrudPermissionsLink, then in setup() I call the function I made:
public function setup()
{
CRUD::setModel(\App\Models\ProgramUnit::class);
CRUD::setRoute(config('backpack.base.route_prefix') . '/programunit');
CRUD::setEntityNameStrings('programunit', 'program_units');
$this->linkPermissions();
}
Then in my trait, I have it simply defined based on a naming convention, splitting on dashes.
<?php
namespace App\Http\Traits;
use Illuminate\Support\Facades\Auth;
/**
* Properties and methods used by the CrudPermissionsLink trait.
*/
trait CrudPermissionsLink
{
/**
* Remove access to all known operations by default, reset them based on permissions defined in the format
* entity_name-operation
*
*/
public function linkPermissions()
{
$ui_ops = ['list','create','delete','update'];
$user = Auth::user();
$this->crud->denyAccess($ui_ops);
foreach($ui_ops as $op){
$perm_name = "{$this->crud->entity_name}-{$op}";
if($user->can($perm_name)){
$this->crud->allowAccess($op);
}
}
}
}
What you have will work. That said, I recently created a similar solution for my apps. For my solution, I used an abstract Crud controller as below and all my specific crud controllers extend this class:
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Gate;
use Illuminate\Database\Eloquent\Model;
use Backpack\CRUD\app\Http\Controllers\Operations\ListOperation;
use Backpack\CRUD\app\Http\Controllers\Operations\CreateOperation;
use Backpack\CRUD\app\Http\Controllers\Operations\UpdateOperation;
use Backpack\CRUD\app\Http\Controllers\Operations\DeleteOperation;
use Backpack\CRUD\app\Http\Controllers\CrudController as BaseCrudController;
abstract class CrudController extends BaseCrudController
{
use ListOperation, DeleteOperation;
use CreateOperation { store as traitStore; }
use UpdateOperation { update as traitUpdate; }
/**
* All possible CRUD "actions"
*/
public const CRUD_ACTION_CREATE = 'create';
public const CRUD_ACTION_LIST = 'list'; // synonymous with "read"
public const CRUD_ACTION_UPDATE = 'update';
public const CRUD_ACTION_DELETE = 'delete';
public const CRUD_ACTION_REORDER = 'reorder';
public const CRUD_ACTION_REVISIONS = 'revisions';
/**
* #var array An array of all possible CRUD "actions"
*/
public const ACTIONS = [
self::CRUD_ACTION_CREATE,
self::CRUD_ACTION_LIST,
self::CRUD_ACTION_UPDATE,
self::CRUD_ACTION_DELETE,
self::CRUD_ACTION_REORDER,
self::CRUD_ACTION_REVISIONS,
];
/**
* #var array An array of all CRUD "actions" that are not allowed for this resource
* Add any of the CRUD_ACTION_X constants to this array to prevent users accessing
* those actions for the given resource
*/
public $_prohibitedActions = [
self::CRUD_ACTION_REORDER, // not currently using this feature
self::CRUD_ACTION_REVISIONS, // not currently using this feature
];
/**
* Protect the operations of the crud controller from access by users without the proper
* permissions
*
* To give a user access to the operations of a CRUD page give that user the permissions below
* (where X is the name of the table the CRUD page works with)
*
* `X.read` permission: users can view the CRUD page and its records
* `X.create` permission: users can create records on the CRUD page
* `X.update` permission: users can update records on the CRUD page
* `X.delete` permission: users can delete records on the CRUD page
* `X.reorder` permission: users can reorder records on the CRUD page
* `X.revisions` permission: users can manage record revisions on the CRUD page
*
* #return void
*/
public function setupAccess(): void
{
// get the name of the table the crud operates on
$table = null;
if (isset($this->crud->model) && $this->crud->model instanceof Model) {
/** #var Model $this->crud->Model; */
$table = $this->crud->model->getTable();
}
// for each action, check if the user has permissions
// to perform that action and enforce the result
foreach (self::ACTIONS as $action) {
$requiredPermission = "$table.$action";
// If our model has no $table property set deny all access to this CRUD
if ($table && !$this->isProhibitedAction($action) && Gate::check($requiredPermission)) {
$this->crud->allowAccess($action);
continue;
}
$this->crud->denyAccess($action);
}
}
/**
* Check if the given action is allowed for this resource
* #param string $action One of the CRUD_ACTION_X constants
* #return bool
*/
public function isProhibitedAction($action): bool
{
return in_array($action, $this->_prohibitedActions, true);
}
/**
* Setup the CRUD page
* #throws \Exception
*/
public function setup(): void
{
$this->setupAccess();
}
}
I have a parent class MapItem, and a child class, MapExhibit. My MapExhibit class has a property, $builiding, which ties the exhibit to a particular MapBuilding entity. When the API calls for the JSON, the $building should not appear for MapExhibit entities in particular.
Note I am using the willdurand/Hateoas bundle
Here is my current setup:
/**
* #ORM\Entity(repositoryClass="App\Repository\Map\MapItemRepository")
* #ORM\InheritanceType("JOINED")
* #ORM\DiscriminatorColumn(name="discr", type="string")
* #ORM\DiscriminatorMap({"item" = "MapItem", "bathroom" = "MapBathroom", "building" = "MapBuilding", "bus" = "MapBus", "emergency" = "MapEmergency", "exhibit" = "MapExhibit", "parking" = "MapParking"})
* #Serializer\XmlRoot("mapItem")
* #Hateoas\Relation("self", href = "expr('/api/mapitems/' ~ object.getId())")
*/
abstract class MapItem
{
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
* #Serializer\XmlAttribute
*/
private $id;
...
}
/**
* #ORM\Entity(repositoryClass="App\Repository\Map\MapExhibitRepository")
*
* #Hateoas\Relation(
* "building",
* exclusion = #Hateoas\Exclusion()
* )
*/
class MapExhibit extends MapItem
{
...
/**
* Many emergency devices can belong to one building.
* #ORM\ManyToOne(targetEntity="MapBuilding", inversedBy="emergencyDevices")
* #ORM\JoinColumn(name="building_id", referencedColumnName="id", nullable=true, onDelete="SET NULL")
*/
private $building;
...
public function getBuilding(): ?MapBuilding
{
return $this->building;
}
}
The result is a JSON object that includes the relation from the parent MapItem
"_links":{"self":{"href":"\/api\/mapitems\/29"}}}]
But also includes the data from the exhibit's building. This part should be ignored.
Take a look on exclusion strategies in documentation.
If you would like to always expose, or exclude certain properties. Then, you can do this with the annotations #ExclusionPolicy, #Exclude, and #Expose.
The default exclusion policy is to exclude nothing. That is, all properties of the object will be serialized. If you only want to expose a few of the properties, then it is easier to change the exclusion policy, and only mark these few properties: (...)
So basically, put #Serializer\Exclude() above private $builidng. Don't forget to add use JMS\Serializer\Annotation as Serializer; on top.
I'm using JMS serializer in my Symfony projuect and i have a question about it. I want ot expose property from entity for only one specific method (one route), in other cases i dont want this property to be exposed. I would be appreciate for any advices)
You can probably achieve this using the #Groups annotation on your properties and then tell the serializer which groups to serialize in your controller.
use JMS\Serializer\Annotation\Groups;
class BlogPost
{
/** #Groups({"list", "details"}) */
private $id;
/** #Groups({"list", "details"}) */
private $title;
/** #Groups({"list"}) */
private $nbComments;
/** #Groups({"details"}) */
private $comments;
private $createdAt;
}
And then:
use JMS\Serializer\SerializationContext;
$serializer->serialize(new BlogPost(), 'json', SerializationContext::create()->setGroups(array('list')));
//will output $id, $title and $nbComments.
$serializer->serialize(new BlogPost(), 'json', SerializationContext::create()->setGroups(array('Default', 'list')));
//will output $id, $title, $nbComments and $createdAt.
More info here.
I am prototyping a REST API in Symfony2 with FOSRestBundle using JMSSerializerBundle for entity serialization. With GET request I can use the ParamConverter functionality of SensioFrameworkExtraBundle to get an instance of an entity based on the id request parameter and when creating a new entity with POST request I can use the FOSRestBundle body converter to create a new instance of the entity based on the request data. But when I want to update an existing entity, using the FOSRestBundle converter gives an entity without id (even when the id is sent with the request data) so if I persist it, it will create a new entity. And using SensioFrameworkExtraBundle converter gives me the original entity without the new data so I would have to manually get the data from the request and call all the setter methods to update the entity data.
So my question is, what is the preferred way to handle this situation? Feels like there should be some way to handle this using the (de)serialization of the request data. Am I missing something related to the ParamConverter or JMS serializer that would handle this situation? I do realize that there are many ways to do this kind of things and none of them are right for every use case, just looking for something that fits this kind of rapid prototyping you can do by using the ParamConverter and minimal code required to be written in the controllers/services.
Here is an example of a controller with the GET and POST actions as described above:
namespace My\ExampleBundle\Controller;
use My\ExampleBundle\Entity\Entity;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;
class EntityController extends Controller
{
/**
* #Route("/{id}", requirements={"id" = "\d+"})
* #ParamConverter("entity", class="MyExampleBundle:Entity")
* #Method("GET")
* #Rest\View()
*/
public function getAction(Entity $entity)
{
return $entity;
}
/**
* #Route("/")
* #ParamConverter("entity", converter="fos_rest.request_body")
* #Method("POST")
* #Rest\View(statusCode=201)
*/
public function createAction(Entity $entity, ConstraintViolationListInterface $validationErrors)
{
// Handle validation errors
if (count($validationErrors) > 0) {
return View::create(
['errors' => $validationErrors],
Response::HTTP_BAD_REQUEST
);
}
return $this->get('my.entity.repository')->save($entity);
}
}
And in config.yml I have the following configuration for FOSRestBundle:
fos_rest:
param_fetcher_listener: true
body_converter:
enabled: true
validate: true
body_listener:
decoders:
json: fos_rest.decoder.jsontoform
format_listener:
rules:
- { path: ^/api/, priorities: ['json'], prefer_extension: false }
- { path: ^/, priorities: ['html'], prefer_extension: false }
view:
view_response_listener: force
If you are using PUT, according to REST, you should use a route for the update with the id of the entity in question in the route itself like /entity/{entity}. FOSRestBundle does it that way too.
In your case this should be something like:
/**
* #Route("/{entityId}", requirements={"entityId" = "\d+"})
* #ParamConverter("entity", converter="fos_rest.request_body")
* #Method("PUT")
* #Rest\View(statusCode=201)
*/
public function putAction($entityId, Entity $entity, ConstraintViolationListInterface $validationErrors)
EDIT: It would actually be even better to have two entities injected. One being the current database state and one being the sent data from the client. You can achieve this with two ParamConverter-annotations:
/**
* #Route("/{id}", requirements={"id" = "\d+"})
* #ParamConverter("entity")
* #ParamConverter("entityNew", converter="fos_rest.request_body")
* #Method("PUT")
* #Rest\View(statusCode=201)
*/
public function putAction(Entity $entity, Entity $entityNew, ConstraintViolationListInterface $validationErrors)
This will load the current db state into $entity and the uploaded data into $entityNew. Now you can merge the data as you see fit.
If it's fine for you to just overwrite the data without merging/checking, then use the first option. But keep in mind that this would allow creating a new entity if the client sends a not yet used id if you do not prevent that.
Seems one way would be to use Symfony Form component (with SimpleThingsFormSerializerBundle) as described in http://williamdurand.fr/2012/08/02/rest-apis-with-symfony2-the-right-way/#post-it
Quote from SimpleThingsFormSerializerBundle README:
Additionally all the current serializer components share a common flaw: They cannot deserialize (update) into existing object graphs. Updating object graphs is a problem the Form component already solves (perfectly!).
I also had a problem with the processing of PUT requests using JMS serializer. First of all I would like to automate the processing of queries using the serializer. The put request may not contain the complete data. Part of the data must be map on entity. You can use my simple solution:
/**
* #Route(path="/edit",name="your_route_name", methods={"PUT"})
*
* This parameter is using for creating a current fields of request
* #RequestParam(
* name="id",
* requirements="\d+",
* nullable=false,
* allowBlank=true,
* strict=true,
* )
* #RequestParam(
* name="some_field",
* requirements="\d{13}",
* nullable=true,
* allowBlank=true,
* strict=true,
* )
* #RequestParam(
* name="some_another_field",
* requirements="\d{13}",
* nullable=true,
* allowBlank=true,
* strict=true,
* )
* #param Request $request
* #param ParamFetcher $paramFetcher
* #return Response
*/
public function editAction(Request $request, ParamFetcher $paramFetcher)
{
//validate parameters
$paramFetcher->all();
/** #var EntityManager $em */
$em = $this->getDoctrine()->getManager();
$yourEntity = $em->getRepository('YourBundle:SomeEntity')->find($paramFetcher->get('id'));
//get request params (param fetcher has all params, but we need only params from request)
$data = $request->request->all();
$this->mapDataOnEntity($data, $yourEntity, ['some_serialized_group','another_group']);
$em->flush();
return new JsonResponse();
}
Method mapDataOnEntity you can locate in some trait or in you intermediate controller class. Here is his implementation of this method:
/**
* #param array $data
* #param object $targetEntity
* #param array $serializationGroups
*/
public function mapDataOnEntity($data, $targetEntity, $serializationGroups = [])
{
/** #var object $source */
$sourceEntity = $this->get('jms_serializer')
->deserialize(
json_encode($data),
get_class($targetEntity),
'json',
DeserializationContext::create()->setGroups($serializationGroups)
);
$this->fillProperties($data, $targetEntity, $sourceEntity);
}
/**
* #param array $params
* #param object $targetEntity
* #param object $sourceEntity
*/
protected function fillProperties($params, $targetEntity, $sourceEntity)
{
$propertyAccessor = new PropertyAccessor();
/** #var PropertyMetadata[] $propertyMetadata */
$propertyMetadata = $this->get('jms_serializer.metadata_factory')
->getMetadataForClass(get_class($sourceEntity))
->propertyMetadata;
foreach ($propertyMetadata as $realPropertyName => $data) {
$serializedPropertyName = $data->serializedName ?: $this->fromCamelCase($realPropertyName);
if (array_key_exists($serializedPropertyName, $params)) {
$newValue = $propertyAccessor->getValue($sourceEntity, $realPropertyName);
$propertyAccessor->setValue($targetEntity, $realPropertyName, $newValue);
}
}
}
/**
* #param string $input
* #return string
*/
protected function fromCamelCase($input)
{
preg_match_all('!([A-Z][A-Z0-9]*(?=$|[A-Z][a-z0-9])|[A-Za-z][a-z0-9]+)!', $input, $matches);
$ret = $matches[0];
foreach ($ret as &$match) {
$match = $match == strtoupper($match) ? strtolower($match) : lcfirst($match);
}
return implode('_', $ret);
}
The best way is using JMSSerializerBundle
The problem is JMSSerializer initializes with the default ObjectConstructor for deserialization (setting the fields that are not in the request as null, and making that merge method will also persist null properties to database). So you need to switch this one with the DoctrineObjectConstructor.
services:
jms_serializer.object_constructor:
alias: jms_serializer.doctrine_object_constructor
public: false
Then just deserialize and persist the entity, and it will be filled with the missing fields. When you save to database only the attributes that have changed will be updated on the database:
$foo = $this->get('jms_serializer')->deserialize(
$request->getContent(),
'AppBundle\Entity\Foo',
'json');
$em = $this->getDoctrine()->getManager();
$em->persist($foo);
$em->flush();
Credits to: Symfony2 Doctrine2 De-Serialize and Merge Entity issue
I'm having the same issue as you described, I just do the entity merging manually:
public function patchMembersAction($memberId, Member $memberPatch)
{
return $this->members->updateMember($memberId, $memberPatch);
}
This calls method that does the validation, and then manually calls all the required setter methods. Anyway, I'm wondering about writing my own param converter for such cases.
Another resource which helped me a lot is http://welcometothebundle.com/symfony2-rest-api-the-best-2013-way/. A step by step tutorial which filled in the blanks I had after the resource in the previous comment. Good luck!