Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Data Producer plugins for creating, updating and deleting entities. #1212

Open
wants to merge 7 commits into
base: 8.x-4.x
Choose a base branch
from

Conversation

Sam152
Copy link

@Sam152 Sam152 commented Jun 10, 2021

I have a site with a number of entity types that are linked to mutations for a fairly standard set of CRUD operations. One thing I found that was missing from the module was a fairly generic set of create/update/delete data producers. I built some, which I have been using which are entity type agnostic, so submitting as a PR in case they are useful upstream.

I'm not sure if this fits in with the ethos of module, which seems focused on empowering folks to connect schema to plain PHP entry points, where they can interface with Drupal entities (or anything else) however they like - directly, but I think these could potentially cover a few blindspots that folks often miss, like entity validation or field access. With the number of entity types I was required to put together, I think writing code for each one individually, without an abstraction, to validate access, fields etc. would have been a bit impractical.

Here are some simplified examples of how I've integrated them into my project, but the builder object seems flexible enough that you could mix and match these producers in various ways.

Creating an entity called article:

    $registry->addFieldResolver('Mutation', 'create_article',
      $builder->produce('create_entity')
        ->map('values', $builder->fromArgument('data'))
        ->map('entity_type', $builder->fromValue('article'))
        ->map('entity_return_key', $builder->fromValue('article'))
    );

Updating it:

$registry->addFieldResolver('Mutation', 'update_article',
      $builder->produce('update_entity')
        ->map('values', $builder->fromArgument('data'))
        ->map('entity_return_key', $builder->fromValue('article'))
        ->map(
          'entity',
          $builder->produce('entity_load')
            ->map('access', $builder->fromValue(FALSE))
            ->map('type', $builder->fromValue($entity_type_id))
            ->map('id', $builder
              ->produce('access_array')
              ->map('input', $builder->fromArgument('data'))
              ->map('key', $builder->fromValue('id'))
            )
        )
    );

A specific mutation to publish an entity:

    $registry->addFieldResolver('Mutation', 'publish_article',
      $builder->produce('update_entity')
        ->map('values', $builder->fromValue([
          'published' => TRUE,
        ]))
        ->map('entity_return_key', $builder->fromValue('article'))
        ->map(
          'entity',
          $builder->produce('entity_load')
            ->map('access', $builder->fromValue(FALSE))
            ->map('type', $builder->fromValue('article'))
            ->map('id', $builder
              ->produce('access_array')
              ->map('input', $builder->fromArgument('data'))
              ->map('key', $builder->fromValue('id'))
            )
        )
    );

A delete:

$registry->addFieldResolver('Mutation', 'delete_article',
      $builder->produce('delete_entity')
        ->map(
          'entity',
          $builder->produce('entity_load')
            ->map('access', $builder->fromValue(FALSE))
            ->map('type', $builder->fromValue('article'))
            ->map('id', $builder
              ->produce('access_array')
              ->map('input', $builder->fromArgument('data'))
              ->map('key', $builder->fromValue('id'))
            )
        )
    );

The schema:

    input OperationInput {
        id: ID!
    }
    type OperationMutationResponse {
        was_successful: Boolean!
        errors: [Violation!]
    }
    input CreateArticleInput {
        use_case: ID!
        name: String!
        body: String!
        published: Boolean
    }
    input UpdateArticleInput {
        id: ID!
        name: String
        body: String
        published: Boolean
    }
    
    type ArticleMutationResponse {
        article: Article
        errors: [Violation!]
    }

    create_article(data: CreateArticleInput): ArticleMutationResponse
    update_article(data: UpdateArticleInput): ArticleMutationResponse
    publish_article(data: OperationInput): OperationMutationResponse
    delete_article(data: OperationInput): OperationMutationResponse

@joaogarin
Copy link
Member

this sounds awsome 🎉 =) ? cc @klausi @rthideaway

Copy link
Contributor

@klausi klausi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice, phenomenal work! This is pretty mature already, great! Yes, we would very much appreciate some out of the box data producer mutations for content entities :)

I think I saw one security issue with missing field access checks on entity creation and I have a bunch of other thoughts added here. But don't let this discourage you, I think 80% of work is done here and we just need to tweak this a bit.

Another important aspect of this is documentation. The schema mapping examples you did are quite good, but they are also complicated. I had to read them a couple of times to remember how this all worked, so this will be even harder for newbies. We should follow up with a very concrete docs update after this is finished, using the examples that you already provided here.

Thanks a lot!

* required = TRUE
* ),
* "values" = @ContextDefinition("any",
* label = @Translation("Values to update"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be "Field values for creating the entity"

* required = TRUE
* ),
* "entity_return_key" = @ContextDefinition("string",
* label = @Translation("Entity Return Key"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be "Key name in the returned array where the entity will be placed"

];
}

$entity = $storage->create($values);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this take create field access into account? If yes we should add a code comment like "// The storage will check that only fields can be set that the user has access to."

Otherwise we would have a security vulnerability here.

Again looking at JSONAPI they return "/data/$data_member_name/$public_field_name", sprintf('The current user is not allowed to POST the selected field (%s).', so I think we should do the same.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, the core API seems a little sketchy.

From JSON API:

      // Only check 'edit' permissions for fields that were actually submitted
      // by the user. Field access makes no distinction between 'create' and
      // 'update', so the 'edit' operation is used here.

From the field access docs:

   * @param string $operation
   *   The operation access should be checked for.
   *   Usually one of "view" or "edit".
...
   * @param \Drupal\Core\Field\FieldItemListInterface $items
   *   (optional) The field values for which to check access, or NULL if access
   *    is checked for the field definition, without any specific value
   *    available. Defaults to NULL.
...
  public function fieldAccess($operation, FieldDefinitionInterface $field_definition, AccountInterface $account = NULL, FieldItemListInterface $items = NULL, $return_as_object = FALSE);

Kind of begs some additional questions like, is the API invoked before or after the field values are hydrated? In the JSON API case it looks like after, so I guess it makes sense to follow that approach.

* id = "create_entity",
* name = @Translation("Create Entity"),
* produces = @ContextDefinition("entity",
* label = @Translation("Entity")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it you are not producing an entity directly but an array that has the entity wrapped in it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, is "any" the most appropriate type here or can the array somehow be described?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not that I know of, so "any" will have to do for now.

$accessHandler = $this->entityTypeManager->getAccessControlHandler($entity_type);

// Ensure the user has access to create this kind of entity.
$access = $accessHandler->createAccess(NULL, NULL, [], TRUE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should pass the bundle here, so I think we need a bundle parameter for this resolver.

* @return array|null
* Get a list of violations or NULL if none were found.
*/
public function getViolationMessages(ContentEntityInterface $entity) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use a return type hint of array here and never return NULL to be more consistent.

* Get a list of violations or NULL if none were found.
*/
public function getViolationMessages(ContentEntityInterface $entity) {
$violations = $entity->validate();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to filter out validation errors that the user does not have access to, like EntityValidationTrait in the JSONAPI core module.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, but no test added just yet.

/**
* Resolve the values for this producer.
*/
public function resolve(ContentEntityInterface $entity, array $values, string $entity_return_key, $context) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are type hinting to content entities - then I think we should rename all data producers to update_content_entity etc.?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, maybe some of them are using entity API in such a way that config entities wouldn't be off the table, but I haven't looked into that too deeply. At the very least, I can't imagine deleting a content and config entity would require any specific content entity functionality.

$access = $entity->{$field_name}->access('edit', NULL, TRUE);
$context->addCacheableDependency($access);
return $access->isAllowed();
}, ARRAY_FILTER_USE_KEY);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a good idea to silently filter out fields that the user does not have edit access to?

JSONAPI has more elaborate code + comments on this, check checkPatchFieldAccess() which gives reasoning for inaccessible read-only fields such as the ID vs. really not accessible fields.

I think we need to do the same.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe in this case it's better to simply return a list of errors if any fields are present the user does not have access to update, rather than to complete half the updates and fail on the other half. I don't know if this logic applies to graphql:

    // The user might not have access to edit the field, but still needs to
    // submit the current field value as part of the PATCH request. For
    // example, the entity keys required by denormalizers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, we should do the same as JSONAPI I think. Let the consumer submit special fields like ID but do not set them and ignore them.

foreach ($update_fields as $field_name => $field_value) {
$entity->set($field_name, $field_value);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entity revision support is missing here like in JSONAPI EntityResource, but we don't have to do all the things in this PR, so fine for me if we skip it for now.

@joaogarin
Copy link
Member

I dont mind being pulled in for the docs work, inside this or in a followup pr

@Sam152
Copy link
Author

Sam152 commented Jun 14, 2021

Nice, thanks for the review and offer to help with docs. I've pushed some changes based on the feedback, but there is still some work to do.

@klausi
Copy link
Contributor

klausi commented Jun 16, 2021

Thanks Sam! Another thing: What happens if you send a request to delete an ID that does not exist? Do you get an ugly 500 error then because the resolver call fails with a fatal type mismatch error? Same for the update resolver, we should have 2 test cases for that.

We could do the actual entity loading in the resolver if that fits better and return a proper error message?

@Sam152
Copy link
Author

Sam152 commented Jun 17, 2021

All good points raised, I may not have a lot of time to see this fully through to fruition, if anyone is following this and feels like they are interested in picking it up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants