I have an User entity that can add his/hers contacts from Google using GooglePeopleApi.
The API provides an array of contacts to the front-end as it is (Nextjs). The question is how to insert all those contacts in one single mutation.
I could, of course, have the front-end loop through the array and post the contacts one by one, but that is a bit silly.
It should be possible to create a type array with the Contact input and then, with that, set up a customArgsMutation. ( I've seen some examples of that from Hasura ).
Entity wise, it looks like this for now ( only relevant code ):
User.php
....
/**
* #ORM\OneToMany(targetEntity="App\Entity\Contact", mappedBy="user")
* #Groups({"put-contacts", "get-admin", "get-owner"})
*/
private $contacts;
Contact.php
/**
* #ApiResource(
* attributes={"pagination_enabled"=false},
* graphql={
* "item_query"={
* "normalization_context"={"groups"={"get-admin", "get-owner"}},
* },
* "collection_query"={
* "normalization_context"={"groups"={"get-admin", "get-owner"}},
* },
* "delete"={"security"="is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user"},
* "create"={
* "security"="is_granted('IS_AUTHENTICATED_FULLY')",
* "denormalization_context"={"groups"={"post", "put"}},
* "normalization_context"={"groups"={"get-owner", "get-admin"}},
* },
* }
* )
* #ORM\Entity(repositoryClass="App\Repository\ContactRepository")
*/
class Contact
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="integer")
*/
private $id;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="contacts")
* #Groups({"post", "get-admin", "get-owner"})
*/
private $user;
/**
* #ORM\Column(type="string", length=180)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $email;
/**
* #ORM\Column(type="string", length=180, nullable=true)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $familyName;
/**
* #ORM\Column(type="string", length=180, nullable=true)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $givenName;
/**
* #ORM\Column(type="string", length=180, nullable=true)
* #Groups({"post", "put", "get-admin", "get-owner"})
*/
private $displayName;
From graphiql, the createContact input looks like this:
user: String
email: String!
familyName: String
givenName: String
displayName: String
clientMutationId: String
You have a couple of options here, depending on the amount of concurrency you want:
1. These should be performed in Serial
The client can make a single HTTP request with multiple mutations as aliases:
mutation CreateUsers {
user1: createUser({ //userInput1 }) { ...userFields }
user2: createUser({ //userInput2 }) { ...userFields }
user3: createUser({ //userInput3 }) { ...userFields }
}
fragment userFields on CreateUserPayload {
firstName
// etc
}
The response will look like this:
{
"data": {
"user1": {...},
"user2": {...},
"user3": {...},
}
}
Pros:
If any single mutation fails, just that one will error out without special handling
Order is maintained. Because the API consumer is specifically labelling the mutations, they know which one has which results.
Cons:
By design, multiple mutations run in Serial, where the first must complete fully before the next one is started. Because of this it's going to be a bit slower.
The client has to add the fields themselves or use a fragment for each mutation (what I showed above)
2. These should be performed in Parallel
It is a common practice (as common as it can be, I guess) to create a "bulk mutation" that allows you to create multiple users.
mutation CreateUsers {
createUsers([
{ //userInput1 },
{ //userInput2 },
{ //userInput3 }
]) {
firstName
// etc
}
}
Pros:
Runs in parallel, so it's faster
The client doesn't have to do string interpolation to build the query. They just need the array of objects to pass in.
Cons:
You have to build the logic yourself to be able to return nulls vs errors on your own.
You have to build the logic to maintain order
The client has to build logic to find their results in the response array if they care about order.
This is how I did it:
Create a custom type:
class CustomType extends InputObjectType implements TypeInterface
{
public function __construct()
{
$config = [
'name' => 'CustomType',
'fields' => [
'id_1' => Type::nonNull(Type::id()),
'id_2' => Type::nonNull(Type::id()),
'value' => Type::string(),
],
];
parent::__construct($config);
}
public function getName(): string
{
return 'CustomType';
}
}
Register CustomType in src/config/services.yaml
(...)
App\Type\Definition\CustomType:
tags:
- { name: api_platform.graphql.type }
Add custom mutation to entity, using CustomType argument as array:
/**
* #ApiResource(
* (...)
* graphql={
* "customMutation"={
* "args"={
* (...)
* "customArgumentName"={"type"=[CustomType!]"}
* }
* }
*)
Then the new customMutation can be called with an array of arguments like this:
mutation myMutation($input: customMutationEntitynameInput!) {
customMutationEntityname(input: $input) {
otherEntity {
id
}
}
}
And graphql variables:
{
"input": {
"customArgumentName": [
{
"id_1": 3,
"id_2": 4
},{
"id_1": 4,
"id_2": 4
}
]
}
}
Related
I have a big problem with Symfony, Doctrine and partially incorrect data.
First of all
i use uuids for primary keys
i use jwts for authentication
i use symfony 5.4.x and doctrine-bundle 2.7.x
i use class inheriance for getting my pk on each entity
i donĀ“t use at all relations, because i want to have some flexibility to slice components into their origin e.g. security component as a microservice (but i think this is not really neccesarry on my question)
Sample of JWT content
{
"iat": xxx,
"exp": xxx,
"roles": [
"MY_ROLE"
],
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"tenant": {
"id": "11111111-2222-3333-4444-555555555555",
"name": "MyTenant"
},
"user": {
"id": "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
"username": "MyUser",
"firstname": "My",
"name": "User"
}
}
Sample of Entity: AbstractEntity.php
<?php
namespace App\Entity;
use JsonSerializable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
/**
* Class AbstractEntity
* #package App\Entity
*
* #ORM\MappedSuperclass()
*/
abstract class AbstractEntity implements EntityInterface, JsonSerializable
{
/**
* #ORM\Id
* #ORM\Column(type="uuid", unique=true)
* #ORM\GeneratedValue(strategy="CUSTOM")
* #ORM\CustomIdGenerator(class=UuidGenerator::class)
*/
private ?Uuid $id = null;
/**
* #return Uuid|null
*/
public function getId(): ?Uuid
{
return $this->id;
}
/**
* #return bool
*/
public function isNew(): bool
{
return !isset($this->id);
}
/**
* #return array
*/
public function jsonSerialize(): array
{
return [
'id' => $this->getId()->toRfc4122()
];
}
/**
* #return Uuid
*/
public function __toString(): string
{
return $this->getId()->toRfc4122();
}
}
Sample of Entity: Settings.php
<?php
namespace App\Entity\Configuration;
use DateTime;
use App\Entity\AbstractEntity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* #ORM\Entity(repositoryClass="App\Repository\Configuration\SettingsRepository")
* #ORM\Table(name="configuration_settings", indexes={#ORM\Index(columns={"tenant"})})
*/
class Settings extends AbstractEntity
{
/**
* #var Uuid $tenant
*
* #ORM\Column(type="uuid")
*/
private Uuid $tenant;
/**
* #var string|null $payload
*
* #ORM\Column(type="json", nullable=true)
*/
private ?string $payload = "";
/**
* #return Uuid
*/
public function getTenant(): Uuid
{
return $this->tenant;
}
/**
* #param Uuid $tenant
*/
public function setTenant(Uuid $tenant): void
{
$this->tenant = $tenant;
}
/**
* #return string|null
*/
public function getPayload(): ?string
{
return $this->payload;
}
/**
* #param string|null $payload
*/
public function setPayload(?string $payload): void
{
$this->payload = $payload;
}
}
Sample of Controller: SettingsController.php
<?php
namespace App\Controller\Configuration\API;
use App\Entity\Configuration\Settings;
use App\Entity\Security\Role;
use App\Repository\Configuration\SettingsRepository;
use App\Service\Security\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Uid\Uuid;
/**
* Settings API controller.
*
* #Route("/api/configuration/settings", name="api_configuration_settings_")
*/
class SettingsController extends AbstractController
{
/**
* #var SettingsRepository
*/
private SettingsRepository $repository;
/**
* SettingsService constructor.
* #param EntityManagerInterface $entityManager
*/
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(Settings::class);
}
/**
* #Route("/obtain", name="obtain", methods={"GET"})
*/
public function obtain(UserService $userService, TokenStorageInterface $tokenStorageInterface, JWTTokenManagerInterface $jwtManager): JsonResponse
{
$userService->isGranted([
Role::ROLE_TENANT_USER
]);
$token = $jwtManager->decode($tokenStorageInterface->getToken());
$tenantAsRFC4122 = $token['tenant']['id']; // Here we have: 11111111-2222-3333-4444-555555555555
$tenant = Uuid::fromString($tenantAsRFC4122); // Here we have a class of Symfony\Component\Uid\UuidV6;
$settings = $this->repository->findOneBy([
'tenant' => $tenant
]);
return new JsonResponse(
$settings
);
}
}
If i now call the API-URL with the Authorization Header including the Bearer as JWT, i will get the Settings for my tenant 11111111-2222-3333-4444-555555555555. But this works not always ...
I can't describe it exactly, because I can't reproduce it, but on one of the systems with a lot of traffic it happens from time to time that despite the correct bearer, i.e. correct tenant, simply wrong settings for another tenant come back.
Is there a doctrine cache somewhere that might cache queries and assign them to the wrong repsonses at the end of the day?
SELECT payload FROM configuration_settings WHERE tenant = '0x1234567890123456789' // here doctrine automaticly strips it to hex
I am so perplexed, because the SQL behind it looks correct and right, here times as an example shortened.
Hope someone can help me.
Thanks,
S.
I created my query builder, with a join and a condition, but the returned result on the joined data is full and is not filtered according to my join condition (group.id = 3).
How to modify my query or entities to get the affiliations and groups filtered accordingly?
Thank you.
Query in repository
$affiliationsGroup = 3;
$query = $this->createQueryBuilder('u');
$query->join('u.affiliations', 'a');
$query->join('a.group', 'g');
$query->andWhere('g.id = :group');
$query->setParameter('group', $affiliationsGroup);
$query->getQuery();
returned result
[
{
"id": 42,
"name":"user42";
"affiliations": [
{
"id": 1,
"group": {
"id": 1,
"name": "group1",
}
},
{
"id": 94,
"group": {
"id": 3,
"name": "group3"
},
}
]
User Entity
/**
* #ORM\OneToMany(targetEntity=Affiliations::class, mappedBy="user", cascade={"persist"})
* #Serializer\Groups({"student"})
*/
private $affiliations;
Affiliation Entity
/**
* #var \Users
*
* #ORM\ManyToOne(targetEntity="Users", inversedBy="affiliations", cascade={"persist"})
* #ORM\JoinColumns({
* #ORM\JoinColumn(name="user_id", referencedColumnName="id")
* })
* #Serializer\Groups({"student"})
*/
private $user;
/**
* #var \Groups
*
* #ORM\ManyToOne(targetEntity="Groups", inversedBy="affiliations")
* #ORM\JoinColumns({
* #ORM\JoinColumn(name="group_id", referencedColumnName="id")
* })
* #Serializer\Groups({"public", "student"})
*/
private $group;
Controller User
/**
*
* Get all users
*
* #Rest\Get("/api/admin/users", name="get_users")
* #Rest\View(serializerGroups={"student"})
* #param Request $request
* #return View
*/
public function indexAction(Request $request): View
{
//Access repository and service to call function and my query builder
}
While the results might not be as expected, there is nothing wrong with your code. It filters correctly.
To understand what's happening, you should understand the two steps:
User 42 (I see what you did here) has at least 1 affiliation in group 3 so is part of the result.
Your serializer will serialize the results. And since user 42 has two affiliations, it will serialize them as well.
Since the serializer doesn't know which filters are applied when querying the results, it doesn't apply those filters.
Unfortunately, this is how API Platform works: it doesn't filter nested entities. See this issue for more details/discussion. You can find some dirty tricks to change the behaviour, but when I encountered the same issue in my project, I decided to filter the nested entities in the front end instead.
I'm looking for the correct way to serialize a child of my object.
I have the following classes:
class company {
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
* #Groups ({"get"})
*/
private $id;
}
class User {
/**
* #ORM\Column(type="string", length=180, unique=true)
* #Groups ({"get"})
*/
private $email;
/**
* #ORM\ManyToOne(targetEntity=Company::class, inversedBy="users")
* #ORM\JoinColumn(nullable=false)
* #Groups ({"get"})
*/
private $company;
}
As soon as I use the serializer on a User object, I receive the following response.
{
"id": 1,
"email": "email#mydomain.com",
"company": {
"id": 1
}
}
But I prefer the following response, how can I get these?
{
"id": 1,
"email": "email#mydomain.com",
"company": 1
}
You can create a custom normalizer, and summarise the company within the User - as it is being converted from the original object (with sub-entities) to an array, before being json-encoded.
symfony console make:serializer:normalizer [optional name, eg: 'UserNormalizer']
This creates a new class, with in part, the contents:
public function normalize($object, $format = null, array $context = []): array
{
// $object is a User entity at this point
$data = $this->normalizer->normalize($object, $format, $context);
// Here: add, edit, or delete some data
// and we summarise the company entity to just the ID.
$data['company'] = $object->getCompany()->getId();
return $data;
}
When I did this with a slightly more complex entity that referred back to the original one (if company had a reference back to a user), it made 'A circular reference', so I added an annotation to #Ignore the field in the User entity, for serialization purposes. It was still given to the normalizer, to use from the object passed into normalize().
You could also serialize the result of a method instead:
class User {
/**
* #ORM\Column(type="string", length=180, unique=true)
* #Groups ({"get"})
*/
private $email;
/**
* #ORM\ManyToOne(targetEntity=Company::class, inversedBy="users")
* #ORM\JoinColumn(nullable=false)
*/
private $company;
/**
* #Groups ({"get"})
*/
public function getCompanyId()
{
return $this->company->getId();
}
}
My problem is the security implementation of the entities when consumed through GraphQL, the queries are perfect, they return the necessary data, but the problem occurs when a query is generated and the many-to-one relationship is limited to having a role "ROLE_ADMIN", the query returns the data even when the user has the role "IS_AUTHENTICATED_ANONYMOUSLY"
How to add the security layer so that data from protected relationships cannot be obtained?
The product query must be seen without any role.
Additional Information
GraphQL Query User Admin
query {
userAdmin(id: "api/user_admins/1") {
id
name
}
}
GraphQL Query User Admin Result OK
{
"errors": [
{
"message": "Sorry, but you don't have access",
"extensions": {
"category": "graphql"
}
]
}
GraphQL Query Product
query {
products {
edges {
node {
name
price
user {
name
}
}
}
}
}
GraphQL Query Product Result FAILED
{
"data": {
"products": {
"edges": [
{
"node": {
"name": "GERLACH-HAAG",
"price": "175",
"user": {
"name": "Sidney Deane" /** this information should not be seen **/
}
}
}
]
}
}
}
Entity Product Configuration
<?php
/**
* #ApiResource(
* graphql={
* "item_query",
* "collection_query",
* "delete"={ "security" = "is_granted('ROLE_ADMIN')" },
* "create"={ "security" = "is_granted('ROLE_ADMIN')" },
* "update"={ "security" = "is_granted('ROLE_ADMIN')" }
* }
* )
* #ORM\Table(name="TBL_PRODUCTS")
* #ORM\Entity(repositoryClass=ProductRepository::class)
* #ORM\HasLifecycleCallbacks()
*/
class Product
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="bigint", name="ID")
*/
private $id;
/**
* #ORM\Column(type="string", length=180, name="NAME")
*/
private $name;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\UserAdmin")
* #ORM\JoinColumn(name="USER", referencedColumnName="ID")
*/
private $user;
Entity User Admin Configuration
<?php
/**
* #ApiResource(
* graphql={
* "item_query"={ "security" = "is_granted('ROLE_ADMIN')" },
* "collection_query"={ "security" = "is_granted('ROLE_ADMIN')" },
* "delete"={ "security" = "is_granted('ROLE_ADMIN')" },
* "create"={ "security" = "is_granted('ROLE_ADMIN')" },
* "update"={ "security" = "is_granted('ROLE_ADMIN')" }
* }
* )
* #ORM\Table(name="TBL_USERS_ADMIN")
* #ORM\Entity(repositoryClass=UserAdminRepository::class)
* #ORM\HasLifecycleCallbacks()
*/
class UserAdmin implements UserInterface
{
/**
* #ORM\Id()
* #ORM\GeneratedValue()
* #ORM\Column(type="bigint", name="ID")
*/
private $id;
/**
* #ORM\Column(type="string", length=180, name="USERNAME")
*/
private $username;
/**
* #ORM\Column(type="string", length=180, name="PASSWORD")
*/
private $password;
/**
* #ORM\Column(type="string", length=180, name="NAME")
*/
private $name;
Please help !!!!
The security attribute allows to define roles or permissions necessary to execute a given query. However, it doesn't define which relation available trough this query the user can access. To do so, you can use dynamic serialization groups.
Basically, mark the properties requiring a special role to be returned in the response with a specific serialization group, such as #Groups("admin"), then create a dynamic serialization context builder to add the special group if the connected user has this special role:
<?php
namespace App\Serializer;
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
use App\Entity\Book;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class BookContextBuilder implements SerializerContextBuilderInterface
{
private $decorated;
private $authorizationChecker;
public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker)
{
$this->decorated = $decorated;
$this->authorizationChecker = $authorizationChecker;
}
public function create(?string $resourceClass, string $operationName, array $resolverContext, bool $normalization): array
{
$context = $this->decorated->create($resourceClass, $operationName, $resolverContext, $normalization);
$resourceClass = $context['resource_class'] ?? null;
if ($resourceClass === Book::class && isset($context['groups']) && $this->authorizationChecker->isGranted('ROLE_ADMIN') && false === $normalization) {
$context['groups'][] = 'admin';
}
return $context;
}
}
In the master version (API Platform 2.6), the security attribute is now also available for properties. Unfortunately, this feature is currently available only in the REST subsystem, but we will be very pleased to merge a Pull Request adding support for this attribute to the GraphQL subsystem.
Try using exclusion policies on your entities. You will need to use the JMS Serializer Bundle
Example:
use JMS\Serializer\Annotation as Serializer;
/**
* #ORM\Table(name="TBL_PRODUCTS")
* #ORM\Entity(repositoryClass=ProductRepository::class)
* #ORM\HasLifecycleCallbacks()
* #Serializer\ExclusionPolicy("all")
*/
class Product
{
/**
* #ORM\ManyToOne(targetEntity="App\Entity\UserAdmin")
* #ORM\JoinColumn(name="USER", referencedColumnName="ID")
* #Serializer\Groups({"ROLE_ADMIN"}) // only users with ROLE_ADMIN will see this
* #Serializer\Expose()
*/
private $user;
/**
* ...
* #Serializer\Groups({"ROLE_SUPER_ADMIN"}) // only users with ROLE_SUPER_ADMIN will see this
* #Serializer\Expose()
*/
private $superSecretData;
...
}
This wil expose the $user property only when the user has the ROLE_ADMIN.
In your controller/service where you expose your data, you should add the users role to the serialization context:
use JMS\Serializer\SerializerInterface;
...
$entities = $repository->findAll();
$responseData = $this->serializer->serialize(
$entities,
'json',
SerializationContext::create()
->setGroups(['ROLE_USER']) // Add all roles you want
);
$response = new Response(
$responseData,
Response::HTTP_OK
);
$response->headers->set('Content-Type', 'application/json');
return $response;
This way you should be able to exclude or expose some properties based on the users' role.
I am afraid I might have ran into some sort of XY problem...
I have an entity "Asset" with related "AssetType" entity (One AssetType can have many Asset entities)
When creating new entity with POST method, the request fails with "SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'type_id' cannot be null"
Data posted from react-admin (POST to /api/assets route):
{
"data":{
"type":"assets",
"attributes":{
"name":"asdf",
"description":"LoraWAN enabled sensor"
},
"relationships":{
"asset_type":{
"data":{
"id":"/api/asset_types/a71b47b8-b9fb-11ea-b4d5-e6b986f12daf",
"type":"asset_types"
}
}
}
}
}
I understand that there is data lost somewhere doing deserialization of object, but cannot figure out where. Also I have identical set of entities (Gateway and Location where each Location can have multiple Gateways) and the creation of new entities work as expected...
New to Symfony & api-platform, any help appreciated.
Asset entity is set tup to be visible in api-platform:
/**
* #ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "delete"},
* normalizationContext={"groups"={"read"}},
* denormalizationContext={"groups"={"write"}}
* )
* #ORM\Entity(repositoryClass="App\Repository\AssetRepository")
*/
class Asset
{
/**
* #ORM\Id
* #ORM\Column(type="uuid_binary_ordered_time", nullable=false, unique=true)
* #ORM\GeneratedValue(strategy="CUSTOM")
* #ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidOrderedTimeGenerator")
* #Groups({"read"})
*/
private $uuid;
/**
* #ORM\ManyToOne(targetEntity="App\Entity\AssetType", inversedBy="assets", cascade={"persist"})
* #ORM\JoinColumn(name="type_id", referencedColumnName="uuid", nullable=false)
*
* #Groups({"read", "write"})
*/
private $assetType;
}
AssetType entity:
/**
* #ApiResource(
* normalizationContext={"groups"={"read"}},
* denormalizationContext={"groups"={"write"}}
* )
* #ORM\Entity(repositoryClass="App\Repository\AssetTypeRepository")
*/
class AssetType
{
/**
* #ORM\Id()
* #ORM\Column(name="uuid", type="uuid_binary_ordered_time", nullable=false, unique=true)
* #ORM\GeneratedValue(strategy="CUSTOM")
* #ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidOrderedTimeGenerator")
* #Groups({"read", "write"})
*/
private $uuid;
/**
* #ORM\Column(type="string", length=255, nullable=true)
* #Groups({"read", "write"})
*/
private $name;
/**
* #ORM\OneToMany(targetEntity="App\Entity\Asset", mappedBy="assetType")
*/
private $assets;
public function __construct()
{
$this->assets = new ArrayCollection();
}
public function getUuid()
{
return $this->uuid;
}
public function setUuid($uuid): self
{
$this->uuid = $uuid;
return $this;
}
public function getAssets(): Collection
{
return $this->assets;
}
public function addAsset(Asset $asset): self
{
...
}
public function removeAsset(Asset $asset): self
{
...
}
In case anyone sumbles across similar problem - the reason for missing values was property naming and its normalization.
Relationship data posted contains key "asset_type" which needs to be converted to camelCase "assetType" in react-admin's dataProvider (that's the approach I took).