Problem / Motiviation

To support loading revisions and to comply with the JSON specification for feature pertaining to resource versions, the jsonapi will "negotiate" the Drupal revision id value for a requested entity, allowing a URL to request an entity revision using a query parameter with a value conforming to a specific structure.

Proposed Resolution

Create a Drupal plugin that will provide the negotiation of revision id values using data submitted to a json api route in a new query parameter. The query parameter value will take the form {negotiator}:{value}, where type is a Drupal plugin id for a revision id negotiator plugin and value is a supported version value, beginning with one of working-copy, latest-version, or a Drupal revision id value.

# Assuming a page node loaded via the json api: jsonapi/node/page/{uuid}

# Load the working copy of the the page node
GET /jsonapi/node/page/{uuid}?resource_version=rel:working-copy

# Load the latest version of the the page node
GET /jsonapi/node/page/{uuid}?resource_version=rel:latest-version

# Load a version using a specific Drupal revision id value.
GET /jsonapi/node/page/{uuid}?resource_version=id:2

CommentFileSizeAuthor
#289 2992833--revision-id-negotiation--289.patch22.42 KBbarbun
#286 2992833-286.patch115.75 KBwim leers
#286 interdiff.txt4.39 KBwim leers
#284 2992833-284.patch110.66 KBgabesullice
#284 interdiff.txt1.89 KBgabesullice
#282 2992833-282.patch110.62 KBgabesullice
#282 interdiff.txt4.8 KBgabesullice
#277 interdiff.txt1.03 KBgabesullice
#276 2992833-276.patch108.58 KBgabesullice
#276 interdiff.txt679 bytesgabesullice
#274 2992833-274.patch109.12 KBgabesullice
#274 interdiff.txt698 bytesgabesullice
#272 2992833-272.patch109.12 KBgabesullice
#272 interdiff.txt749 bytesgabesullice
#270 interdiff.txt5.14 KBgabesullice
#270 2992833-270.patch109.08 KBgabesullice
#269 2992833-269.patch109.31 KBgabesullice
#262 2992833-262.patch109.29 KBgabesullice
#262 interdiff.txt3.67 KBgabesullice
#256 2992833-256.patch119.37 KBwim leers
#256 interdiff.txt4.39 KBwim leers
#244 2992833-243.patch116 KBgabesullice
#244 interdiff.txt1.34 KBgabesullice
#240 2992833-238.patch116 KBgabesullice
#240 interdiff.txt7.42 KBgabesullice
#232 2992833-232.patch107.43 KBgabesullice
#232 interdiff.txt1.04 KBgabesullice
#230 2992833-230.patch107.42 KBgabesullice
#230 interdiff.txt6.22 KBgabesullice
#229 2992833-229.patch106.32 KBgabesullice
#225 2992833-225.patch105.75 KBgabesullice
#225 interdiff.txt3.6 KBgabesullice
#220 2992833-220.patch104.62 KBgabesullice
#220 interdiff.txt11.36 KBgabesullice
#220 interdiff-json-api-name.txt7.75 KBgabesullice
#217 2992833-217.patch101.98 KBgabesullice
#214 2992833-214-plugin-api.patch105.72 KBgabesullice
#214 2992833-214-service-collector.patch101.5 KBgabesullice
#214 interdiff-test-annotations.txt545 bytesgabesullice
#212 2992833-212-plugin-api.patch105.72 KBgabesullice
#212 2992833-212-service-collector.patch101.39 KBgabesullice
#212 interdiff-service-collector-tests.txt11.18 KBgabesullice
#212 interdiff-permissions.txt1.72 KBgabesullice
#208 2992833-208-service-collector.patch102.56 KBgabesullice
#208 2992833-208-plugin-api.patch105.76 KBgabesullice
#208 interdiff.txt2.49 KBgabesullice
#206 interdiff-easy.txt17.2 KBgabesullice
#205 2992833-205.patch101.3 KBgabesullice
#205 interdiff.txt17.98 KBgabesullice
#204 2992833-204.patch104.5 KBgabesullice
#204 interdiff.txt1.85 KBgabesullice
#202 2992833-202.patch104.05 KBgabesullice
#202 interdiff.txt1.63 KBgabesullice
#200 2992833-200.patch103.88 KBgabesullice
#200 interdiff-remaining.txt2.31 KBgabesullice
#200 interdiff-getrelatedresponses-to-getrelatedresponse.txt7.91 KBgabesullice
#200 interdiff-related-route-assertions.txt8.24 KBgabesullice
#197 2992833-197.patch92.51 KBgabesullice
#197 interdiff.txt716 bytesgabesullice
#195 2992833-195.patch92.45 KBgabesullice
#195 interdiff.txt6.03 KBgabesullice
#193 2992833-193.patch91.01 KBgabesullice
#193 interdiff.txt933 bytesgabesullice
#191 2992833-191.patch90.96 KBgabesullice
#191 interdiff.txt3.19 KBgabesullice
#191 interdiff-cs-violation.txt1.5 KBgabesullice
#188 2992833-188.patch89.69 KBgabesullice
#188 interdiff.txt5.85 KBgabesullice
#187 2992833-187.patch88.88 KBgabesullice
#187 interdiff.txt6.3 KBgabesullice
#186 2992833-186.patch85.24 KBgabesullice
#186 interdiff-collection-tests.txt11.35 KBgabesullice
#186 interdiff-cs-violation.txt639 bytesgabesullice
#183 2992833-183.patch81.54 KBgabesullice
#183 interdiff.txt1.35 KBgabesullice
#182 2992833-182.patch80.86 KBgabesullice
#182 interdiff.txt1.24 KBgabesullice
#177 2992833-177.patch80.98 KBgabesullice
#177 interdiff.txt21.74 KBgabesullice
#174 2992833-174.patch82.99 KBwim leers
#174 interdiff.txt2.6 KBwim leers
#172 2992833-172.patch81.44 KBwim leers
#172 interdiff.txt1.52 KBwim leers
#170 2992833-170.patch79.51 KBgabesullice
#170 interdiff.txt1.64 KBgabesullice
#168 2992833-167.patch78.87 KBgabesullice
#167 interdiff.txt1.3 KBgabesullice
#165 2992833-165.patch78.1 KBgabesullice
#165 interdiff.txt6.18 KBgabesullice
#163 2992833-163.patch76.86 KBgabesullice
#163 interdiff.txt705 bytesgabesullice
#160 2992833-160.patch77.11 KBwim leers
#160 interdiff.txt4.71 KBwim leers
#159 2992833-159.patch76.9 KBwim leers
#159 interdiff.txt1.22 KBwim leers
#157 2992833-157.patch76.46 KBgabesullice
#157 interdiff.txt8.81 KBgabesullice
#156 2992833-156.patch70.97 KBgabesullice
#156 interdiff.txt11.8 KBgabesullice
#155 2992833-155.patch61.38 KBgabesullice
#155 interdiff.txt900 bytesgabesullice
#153 2992833-153.patch61.36 KBgabesullice
#153 interdiff.txt793 bytesgabesullice
#148 2992833-148.patch61.17 KBgabesullice
#148 interdiff.txt3.77 KBgabesullice
#147 2992833-147.patch61.19 KBgabesullice
#147 interdiff.txt31.53 KBgabesullice
#145 2992833-143.patch57 KBgabesullice
#145 interdiff.txt4.99 KBgabesullice
#142 2992833-141.patch54.96 KBgabesullice
#142 interdiff.txt7.41 KBgabesullice
#140 2992833-140.patch54.66 KBgabesullice
#140 interdiff.txt6.02 KBgabesullice
#136 2992833-136.patch54.31 KBgabesullice
#136 interdiff.txt1.45 KBgabesullice
#134 2992833-134.patch55.76 KBgabesullice
#134 interdiff.txt7.54 KBgabesullice
#130 2992833-130.patch56.5 KBgabesullice
#130 interdiff.txt450 bytesgabesullice
#129 2992833-129.patch56.06 KBgabesullice
#129 interdiff.txt726 bytesgabesullice
#128 interdiff.txt642 bytesgabesullice
#125 2992833-125.patch55.35 KBgabesullice
#125 interdiff.txt7.63 KBgabesullice
#124 2992833-124.patch48.49 KBgabesullice
#124 interdiff.txt1.98 KBgabesullice
#121 2992833-121.patch48.9 KBgabesullice
#121 interdiff.txt1.98 KBgabesullice
#120 2992833-120.patch48.8 KBgabesullice
#120 interdiff.txt652 bytesgabesullice
#117 2992833-117.patch48.8 KBgabesullice
#117 interdiff.txt1.47 KBgabesullice
#115 2992833-115.patch49.74 KBgabesullice
#115 interdiff.txt6.43 KBgabesullice
#113 2992833-113.patch51.02 KBgabesullice
#113 interdiff.txt1010 bytesgabesullice
#111 2992833-111.patch51.25 KBgabesullice
#109 2992833-109.patch50.58 KBgabesullice
#109 interdiff.txt2.31 KBgabesullice
#107 2992833-107.patch48.28 KBgabesullice
#107 interdiff.txt532 bytesgabesullice
#105 2992833-105.patch48.32 KBgabesullice
#105 interdiff.txt3.54 KBgabesullice
#103 2992833-103.patch46.41 KBgabesullice
#103 interdiff.txt852 bytesgabesullice
#101 2992833-101.patch46.46 KBgabesullice
#101 interdiff.txt1.11 KBgabesullice
#98 2992833-98.patch45.54 KBgabesullice
#96 2992833-96.patch45.53 KBgabesullice
#96 interdiff.txt8.5 KBgabesullice
#95 2992833-93.patch37.25 KBgabesullice
#94 2992833-93.patch63.95 KBgabesullice
#93 interdiff.txt7.26 KBgabesullice
#91 2992833-90.patch63.38 KBgabesullice
#91 interdiff.txt3.03 KBgabesullice
#89 2992833-89.patch63.43 KBgabesullice
#89 interdiff.txt8.64 KBgabesullice
#87 2992833-86.patch44.26 KBgabesullice
#87 interdiff.txt2.45 KBgabesullice
#85 2992833-85.patch44.12 KBgabesullice
#85 interdiff.txt15 KBgabesullice
#83 2992833-83.patch38.55 KBgabesullice
#83 interdiff.txt5.99 KBgabesullice
#82 2992833-82.patch36.18 KBgabesullice
#82 interdiff.txt2.21 KBgabesullice
#81 2992833-81.patch36.19 KBgabesullice
#81 interdiff.txt14.91 KBgabesullice
#76 2992833-76.patch32.98 KBgabesullice
#76 interdiff.txt4.74 KBgabesullice
#75 2992833-75.patch32.2 KBgabesullice
#75 interdiff.txt3.06 KBgabesullice
#74 2992833-74.patch32.12 KBgabesullice
#74 interdiff.txt1.41 KBgabesullice
#73 2992833-73.patch31.93 KBgabesullice
#73 interdiff.txt5.94 KBgabesullice
#72 2992833-72.patch31.93 KBgabesullice
#72 interdiff.txt12.02 KBgabesullice
#71 2992833-71.patch35.1 KBgabesullice
#71 interdiff.txt4.93 KBgabesullice
#70 2992833-70.patch35.26 KBgabesullice
#46 2992833--revision-id-negotiation--38.patch46 KBjustageek
#45 2992833--revision-id-negotiation--37.patch25.08 KBjustageek
#36 2992833--revision-id-negotiation--36.patch39.01 KBe0ipso
#36 2992833--interdiff--34-36.txt1.41 KBe0ipso
#34 2992833--revision-id-negotiation--34.patch39.06 KBe0ipso
#34 2992833--interdiff--33-34.txt29.09 KBe0ipso
#33 2992833--revision-id-negotiation--33.patch39.39 KBe0ipso
#33 2992833--interdiff--29-33.txt1.92 KBe0ipso
#32 2992833--interdiff--20-29.txt6.85 KBe0ipso
#29 2992833--revision-id-negotiation--29.patch39.28 KBgarphy
#27 interdiff-jsonapi-2992833-20-26.txt6.58 KBgarphy
#27 2992833--revision-id-negotiation--26.patch39.01 KBgarphy
#20 2992833--revision-id-negotiation--20.patch33.38 KBwim leers
#20 interdiff.txt1.23 KBwim leers
#18 2992833--revision-id-negotiation--18.patch33.27 KBe0ipso
#18 2992833--interdiff--16-18.txt1.35 KBe0ipso
#16 2992833--revision-id-negotiation--15.patch33.21 KBe0ipso
#16 2992833--interdiff--14-15.txt1.38 KBe0ipso
#14 2992833--interdiff--13-14.txt10.99 KBe0ipso
#14 2992833--revision-id-negotiation--14.patch33.17 KBe0ipso
#13 2992833--revision-id-negotiation--13.patch28.27 KBe0ipso
#13 2992833--interdiff--12-13.txt546 bytese0ipso
#12 2992833--revision-id-negotiation--12.patch28.11 KBe0ipso
#12 2992833--interdiff--10-12.txt18.3 KBe0ipso
#10 2992833--interdiff--8-10.txt1.62 KBe0ipso
#10 2992833--revision-id-negotiation--10.patch24.83 KBe0ipso
#8 2992833--revision-id-negotiation--8.patch24 KBe0ipso
#6 2-x-revisions.patch13.43 KBgabesullice

Comments

justageek created an issue. See original summary.

justageek’s picture

Issue summary: View changes
justageek’s picture

Issue summary: View changes
wim leers’s picture

Category: Task » Feature request
Issue tags: +API-First Initiative

Thanks for filing this! This would be the first and only PHP API that JSON API has. (See jsonapi.api.php.) If we add this API, then it'd definitely have to be marked @internal. At least initially.

That still would allow you on projects to add your own plugins. But the JSON API module would have no responsibility to retain BC for this PHP API.

gabesullice’s picture

I've started experimenting with this locally and have something that could apply.

Questions that I ran into while doing so:

  1. In Drupal terms, what is a working copy?
  2. Simarlarly, what is a latest revision?
    1. Without content_moderation installed and referencing the definitions given in RFC 5829, I think these are one and the same.
  3. What should the self link be for a resource? Should it include the revision ID negotiator? Perhaps only when it is not the default revision.

Re: the proposed solution:

Can we change current_version to resource_version?

Can we not make this a plugin right now? Rather, let's have code that will make it easy to add different negotiators tomorrow, but hardcode this for now.

Finally, must this be a 1.x issue? This certainly feels like a 2.1 kind of thing. If not, I think we'll need parallel development tracks for 1.x and 2.x. I don't know that the 1.x version should then ever be committed, but instead just be a patch that will continue to apply since the 1.x is not receiving anything but critical support from at least 2 of the 3 most active maintainers.


Most importantly! Thanks for opening an issue! I'm super excited to see this progress!

gabesullice’s picture

StatusFileSize
new13.43 KB

I've started experimenting with this locally and have something that could apply.

Here's that code if it'll help. I got this started off 2.x and haven't tried it on 1.x.

UPDATE: I hadn't seen #2992836: Provide links to resource versions (entity revisions) when I posted this. This patch includes that feature but it's easily extracted.

e0ipso’s picture

since the 1.x is not receiving anything but critical support from at least 2 of the 3 most active maintainers.

🙃

e0ipso’s picture

Status: Active » Needs review
StatusFileSize
new24 KB

This is what we plan on running for our codebase based on 1.x. Reviews will be greatly appreciated.

My current plan is to release and support this feature in 1.x, unless there is a strong reason not to.

Status: Needs review » Needs work

The last submitted patch, 8: 2992833--revision-id-negotiation--8.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

e0ipso’s picture

Status: Needs work » Needs review
StatusFileSize
new24.83 KB
new1.62 KB

Let's see how many of those we fix with these simple updates.

e0ipso’s picture

Issue summary: View changes
e0ipso’s picture

This patch negotiates the revision on a param enhancer. This is inspired on @gabesullice's patch in #6.

PROS of this approach: It's a very well scoped service that responds to URL input and modifies a parameter that will be used later on. It loads the revision for all places that it may be used now and the future.
CONS of this approach: It is a bit hacky to replace the value of a route parameter in the route. It uses a parameter enhancer when there is not a route parameter (we have a query string parameter).

Overall I like the new approach, but I'm willing to switch back to #10 if anyone has concerns about this.

e0ipso’s picture

Fixed DCS.

e0ipso’s picture

This adds support for collections when asking for rel:latest-version. This even works with filtering on future revisions thanks to Entity Query API!

Status: Needs review » Needs work

The last submitted patch, 14: 2992833--revision-id-negotiation--14.patch, failed testing. View results

e0ipso’s picture

Status: Needs work » Needs review
StatusFileSize
new1.38 KB
new33.21 KB

Some additional minor fixes.

Status: Needs review » Needs work

The last submitted patch, 16: 2992833--revision-id-negotiation--15.patch, failed testing. View results

e0ipso’s picture

Status: Needs work » Needs review
StatusFileSize
new1.35 KB
new33.27 KB

Maybe this will fix the tests.

Status: Needs review » Needs work

The last submitted patch, 18: 2992833--revision-id-negotiation--18.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new1.23 KB
new33.38 KB

#12: thanks for explaining PROs and CONs! I was going to ask about collection support, and how the two approaches compare when you add support for that.

But I see that you added that in #14, splendid! 🤘


Attached is a small contribution towards making this pass tests. 😊

wim leers’s picture

#12: thanks for explaining PROs and CONs! I was going to ask about collection support, and how the two approaches compare when you add support for that.

Actually, I'd still like to get your thoughts on this.

In the case of collections, we need to perform an entity query, and hence in that scenario, the param enhancer does not help us at all.

Initial review:

  1. +++ b/src/JsonApiSpec.php
    @@ -96,6 +96,13 @@ class JsonApiSpec {
    +  const VERSION_QUERY_PARAMETER = 'resource_version';
    
    +++ b/src/ResourceType/ResourceType.php
    @@ -196,6 +203,16 @@ class ResourceType {
    +   * Whether resources of this resource type are revisionable.
    +   *
    +   * @return bool
    +   *   TRUE if the resource type's resources are revisionable. FALSE otherwise.
    +   */
    +  public function isVersionable() {
    +    return $this->isVersionable;
    +  }
    
    +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,121 @@
    +class ResourceVersionRouteEnhancer implements EnhancerInterface {
    
    +++ b/src/Revisions/RevisionIdNegotiationInterface.php
    @@ -0,0 +1,33 @@
    +interface RevisionIdNegotiationInterface {
    
    +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -456,6 +456,7 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +      'url.query_args:resource_version',
    

    "versionable" and "revisionable" are being mixed up many times here.

    If we want "version" to be Drupal-agnostic, that's fine by me. But then we need to very clearly document that, and be consistent about it.

  2. +++ b/src/Plugin/RevisionIdNegotiation/IdNegotiation.php
    @@ -0,0 +1,29 @@
    +class IdNegotiation extends PluginNegotiationBase implements RevisionIdNegotiationInterface {
    

    VersionById

  3. +++ b/src/Plugin/RevisionIdNegotiation/RelNegotiation.php
    @@ -0,0 +1,76 @@
    +class RelNegotiation extends PluginNegotiationBase implements ContainerFactoryPluginInterface, RevisionIdNegotiationInterface {
    

    VersionByRel

  4. +++ b/src/Plugin/RevisionIdNegotiation/RelNegotiation.php
    @@ -0,0 +1,76 @@
    +  protected function getRevisionId(EntityInterface $entity, $input_data) {
    ...
    +      case static::CURRENT:
    ...
    +      case static::LATEST:
    

    This looks quite nice!

  5. +++ b/src/Plugin/RevisionIdNegotiation/RelNegotiation.php
    @@ -0,0 +1,76 @@
    +    if (!($entity instanceof RevisionableInterface)) {
    

    This is insufficient. This needs to call \Drupal\Core\Entity\EntityTypeInterface::isRevisionable() instead. See the docs for \Drupal\Core\Entity\RevisionableInterface.

  6. +++ b/src/ResourceType/ResourceTypeRepository.php
    @@ -176,6 +178,20 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
    +    return !empty($interfaces[RevisionableStorageInterface::class]) && $entity_type->isRevisionable();
    

    That call to isRevisionable() is essential, glad to see it here!

    (And yes, that's another Entity API WTF, but alas, nothing we can fix… 🙃)

  7. +++ b/src/Revisions/Annotation/RevisionIdNegotiation.php
    @@ -0,0 +1,27 @@
    +class RevisionIdNegotiation extends Plugin {
    

    Either VersionNegotiation or RevisionNegotiation.

    Right now, this seems to be related to the IdNegotiation class. Very confusing.

  8. +++ b/src/Revisions/PluginNegotiationBase.php
    @@ -0,0 +1,93 @@
    +  public function getRevision(EntityInterface $entity, $input_data) {
    +    return $this
    +      ->entityTypeManager
    +      ->getStorage($entity->getEntityTypeId())
    +      ->loadRevision($this->getRevisionId($entity, $input_data));
    +  }
    +
    +  /**
    +   * Gets the revision id.
    +   *
    +   * @param \Drupal\Core\Entity\EntityInterface $entity
    +   *   The entity.
    +   * @param string $input_data
    +   *   A value used to derive a revision id for the given entity.
    +   *
    +   * @return int
    +   *   The revision id.
    +   *
    +   * @throws \InvalidArgumentException
    +   *   When the revision ID cannot be negotiated.
    +   */
    +  abstract protected function getRevisionId(EntityInterface $entity, $input_data);
    

    Why not make getRevisionId() the plugin interface/API? Then we don't need this logic at all. Then this base class can disappear!

    If there's a reason to have this logic here, I'd love to learn more :)

  9. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,121 @@
    +      || !isset($defaults[Routes::RESOURCE_TYPE_KEY])
    +      // This is a collection request, there is nothing to enhance.
    +      || !isset($defaults[$defaults[Routes::RESOURCE_TYPE_KEY]->getEntityTypeId()])
    ...
    +    /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
    +    $resource_type = $defaults[Routes::RESOURCE_TYPE_KEY];
    

    Use \Drupal\jsonapi\Routing\Routes::getResourceTypeNameFromParameters() instead.

  10. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,121 @@
    +    if (is_null($resolved_revision)) {
    +      $cacheability = (new CacheableMetadata())
    +        ->addCacheContexts(['url.query_args:' . JsonApiSpec::VERSION_QUERY_PARAMETER])
    +        ->addCacheableDependency($entity);
    +      throw new CacheableNotFoundHttpException(
    +        $cacheability,
    +        sprintf(
    +          'The requested resource version, identified by `%s`, could not be found.',
    +          $resource_version_identifier
    +        )
    +      );
    +    }
    

    👏

  11. +++ b/src/Revisions/RevisionIdNegotiationManager.php
    @@ -0,0 +1,68 @@
    +    $this->alterInfo('revision_id_info');
    

    This is probably a force of habit, so probably this is here unintentionally.

    I'd really like this to not have an alter hook. To not create API surface.

  12. +++ b/src/Revisions/RevisionIdNegotiationManager.php
    @@ -0,0 +1,68 @@
    +  public static function throwBadRequestHttpException($resource_version_identifier) {
    

    I … don't like this, but let's wait and see if it disappears automatically as this patch gets improved :)

wim leers’s picture

Two general questions for now:

  1. Mutation support?
  2. Ability to list all revisions? version-history
justageek’s picture

I have what I think are the remaining tests passing locally, I will likely not get this into the patch until next week. I had already added the call to ::isRevisionable() as part of the solution to the failing tests, so glad it is already rolled into the code / patch. Most of the remaining failures were differences in the structure of the json api output for entity types that are revisionable vs those that are not.

I can incorporate updates from the review and comments in #21, let just me know when those are agreed upon.

e0ipso’s picture

In the case of collections, we need to perform an entity query, and hence in that scenario, the param enhancer does not help us at all.

I think that may be OK since in collections we can only support a very limited array of negotiation options. So it makes sense for it to be its own thing. But I don't feel attached to any solution.


Addressing feedback from #21

1. That was semi-intentional. Throwing the terms at the wall to see what sticks. I think I like "versionable" a bit better. You?
2. 👌🏽
3. 👌🏽
4. 🙂
5. Ah, yest. We're are doing it in the isVersionable logic but missed it here. Good catch.
6. Yup.
7. I'm going with VersionNegotiation.
8. We had that prior to #14. Since we will need to load the revision we can either do it in the plugin or as the immediate statement after getting the revision ID. If we do it here we simplify the calling code since we don't have to inject entity type manager everywhere we inject the negotiator plugin manager.
9. 👌🏽
10. Fast errors FTW.
11. Agreed. Let's remove it.
12. I found myself repeating this code in several places and I wanted to abstract into a static method. Do you have suggestions on where to put this? Maybe rename to throwBadRevisionInput?


Mutation support?

I think that GET is a good place to start. My gut feeling is that we'll be able to use the same qs negotiation for mutations. I don't see an obvious problem, but I haven't thought about it much.

Ability to list all revisions?

For now all the version discovery happens via hypermedia links. However this version-history implies the creation of a new endpoint. I don't think we are ready to tackle that. Maybe a follow up? In any case I think this could follow the same structure as our entry point.

wim leers’s picture

However this version-history implies the creation of a new endpoint.

To me, it feels like a collection-like endpoint, that does not return resource objects, but resource identifier objects.

Having written that, it seems like version-history ought to be a virtual relationship on any versionable resource type? What do you think?

P.S.: d'oh, I wanted to remove my points 2 and 3 because I wasn't entirely sure about them. But I forgot.

garphy’s picture

Status: Needs review » Needs work

I just noticed that when using IdNegotiation, you're able to request the revision of another entity that the one your pointing at, because there's no validation that the passed revision id is indeed part of the requested entity revision history.
I'll try to write a test case which exhibits this behavior.

garphy’s picture

Status: Needs work » Needs review
StatusFileSize
new39.01 KB
new6.58 KB

I added initial test coverage for IdNegotiation & RelNegotiation and also added a fix to prevent the use of a revision id of another entity.

Status: Needs review » Needs work

The last submitted patch, 27: 2992833--revision-id-negotiation--26.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

garphy’s picture

Status: Needs work » Needs review
StatusFileSize
new39.28 KB

Forgot to make my test base class abstract.
Also fixed some code styling issues reported by the bot.

wim leers’s picture

you're able to request the revision of another entity that the one your pointing at, because there's no validation that the passed revision id is indeed part of the requested entity revision history.

Woah, great find! 🤯 And one with security implications too … Thanks for adding test coverage! Will review in more detail tomorrow.

e0ipso’s picture

Woah, great find! 🤯 And one with security implications too

I agree that this fixes some DX, but I don't see the security implications? While serial IDs are frowned upon because they allow for discoverability of unlisted content, that can hardly be considered insecure (otherwise REST core would be in trouble).


@garphy thanks for the contribution!

e0ipso’s picture

StatusFileSize
new6.85 KB

In case anyone else is curious, this is the interdiff for the patch above.

e0ipso’s picture

Fixes some DCS.

e0ipso’s picture

This addresses @Wim Leers' feedback in #21. This is mostly renaming stuff. Let's settle with these new names now.

Status: Needs review » Needs work

The last submitted patch, 34: 2992833--revision-id-negotiation--34.patch, failed testing. View results

e0ipso’s picture

Status: Needs work » Needs review
StatusFileSize
new1.41 KB
new39.01 KB

Some renaming woes. I'm expecting more.

Status: Needs review » Needs work

The last submitted patch, 36: 2992833--revision-id-negotiation--36.patch, failed testing. View results

wim leers’s picture

#31: If entity access checks happen first and then you end up loading a revision … but it's of a different entity, then you would've bypassed entity access!

garphy’s picture

#31: If entity access checks happen first and then you end up loading a revision … but it's of a different entity, then you would've bypassed entity access!

I also wondered about security implications of this "bug" but I don't think that the entity access would be bypassed because all of this happen in a route enhancer, thus the wrongly selected entity would be subsequently checked by JSON API request controller afterwards (unless I'm mistaken here).

Everything here rely upon $entity->access() which is called in EntityResource::getIndividual(), after the revision id is resolved.

gabesullice’s picture

Having written that, it seems like version-history ought to be a virtual relationship on any versionable resource type? What do you think?

I disagree. For now, I would like to keep version navigation as a hypermedia-only thing and not try to confuse it with resource fields. That gives us a lot of flexibility and makes it easier to write a profile extension for.

To me, it feels like a collection-like endpoint, that does not return resource objects, but resource identifier objects.

That would be a "relationship-like" route. Again, I have to disagree. I do believe this should be a collection of fully fledged resource objects, w/ support for sparse field sets (and perhaps includes after a v1). This would be far more useful for a revision history type view in a client and diff illustrations.

If you're thinking of version-history as a relationship field, I understand why resource identifiers would feel natural. However, I think making it a relationship field gets us into a conceptually weird recursive self-referential mess and would behave very bizarrely with includes (even though it's "virtual").

A relationship-like endpoint would need to consider how to deal with a PATCH and POST (it would be 404 of course, but considering what it would mean illustrates why it doesn't "fit").


My 3 initial questions in #5 are still open:

  1. In Drupal terms, what is a working copy?
  2. Simarlarly, what is a latest revision?
    1. Without content_moderation installed and using the definitions given in RFC 5829, I think working-copy and latest-version should actually be the same thing.
  3. What should the self link be for a resource? Should it include the revision ID negotiator? Perhaps only when it is not the default revision.
e0ipso’s picture

I'd like to descope version-history for now. It's unclear atm and I believe that it will require a new endpoint. I'll open up a new issue to discuss it. Please post your thoughts there.


In Drupal terms, what is a working copy?

The working copy is the default revision.

Without content_moderation installed and using the definitions given in RFC 5829, I think working-copy and latest-version should actually be the same thing.

Agreed. This is the current behavior.

What should the self link be for a resource? Should it include the revision ID negotiator? Perhaps only when it is not the default revision.

It should reflect the currently loaded resource, hence it should it include the revision ID negotiator. I concur that we should not add the revision dimension if it's the default.

This is not currently the case, so I'll follow up.

wim leers’s picture

version-history

I disagree. For now, I would like to keep version navigation as a hypermedia-only thing and not try to confuse it with resource fields. That gives us a lot of flexibility and makes it easier to write a profile extension for.

I … am not sure how that would work, given that revisions aren't linear: they're a graph. A graph that can't be reconstructed even. You can't even do previous and next links.
And yes, you're right in That would be a "relationship-like" route. :) You convince me that this is a bad idea when you say that I think making it a relationship field gets us into a conceptually weird recursive self-referential mess and would behave very bizarrely with includes (even though it's "virtual").

I'd like to descope version-history for now. It's unclear atm and I believe that it will require a new endpoint. I'll open up a new issue to discuss it. Please post your thoughts there.

It seems you'd both like to descope it, but shouldn't we first have an idea of how to achieve that while achieving the other things, to ensure we don't make it impossible to fit in later?

My concern is that we make it possible to load a specific revision by ID, but how can you even figure out what that revision ID would be without having some listing of available revisions, which version-history would provide?

I'd really appreciate it if either or both of you could share slightly more of your thoughts about how you think this ought to work. That would probably address my concerns about descoping that to a separate issue ☺️🙏

The #5 questions

Asked at the bottom of #40, answered in #41: that all sounds great! 🤘

garphy’s picture

revisions aren't linear: they're a graph

How so ? I thought it was pretty linear. AFAIK, we don't store the "parent" revision of a given revision so we can't really build that graph. Unless I'm mistaken, revisions are only list.

gabesullice’s picture

revisions aren't linear: they're a graph.

This isn't the first time you've said that, like @garphy, I'm a little confused though. I don't know that to be true, even with content_moderation installed. Could you explain how they're a DAG? AFAICT, revisions form a linear history. Certain revisions are marked "default" and with CM installed, the latest version can be in a different state. IOW, there's no concept of merging or forking.

You can't even do previous and next links. ... My concern is that we make it possible to load a specific revision by ID, but how can you even figure out what that revision ID would be without having some listing of available revisions, which version-history would provide?

This comes back to the linear history thing... if it is a linear history, then it's the self, predecessor-version and successor-version links that would reveal the revision ID specific links. Also, self links alone need them.

Regardless, even a DAG would have predecessor-version and successor-version links (plural). RFC5829§3.5 even takes this into account specifically: "Some systems may allow more than one of these link relations in order to support branching."

It seems you'd both like to descope it, but shouldn't we first have an idea of how to achieve that while achieving the other things, to ensure we don't make it impossible to fit in later?

I didn't say that ;) But I agree with Mateu that it doesn't need to be in the patch, but I agree with you that we need to understand how it will work whether or not we implement it. I don't see the point of moving the discussion to a different issue though.

The working copy is the default revision.

I disagree. It's easy to confuse when latest-version and working-copy are the same thing though. So what's my reasoning?

A "working copy" is a resource at a server-defined URL that can be used to create a new version of a versioned resource."

New revisions always proceed from the tip of the version history. So, I think the "working-copy" is the revision with the highest revision ID.

OTOH, "the latest version is defined by the system." Which makes me think that latest-version should be the default version.

Here is how I think about it:

a---b---c---d---e---f---g---h

d is the default version
h is the version with the highest revision ID
h may be published or unpublished
with CM installed, h may be in one of many workflow states
with CM installed any rev a-h may be the default version

latest-version: d
working-copy: h
working-copy-of: g
predecessor-version:latest-version: c
successor-version:latest-version: e
predecessor-version:working-copy: g
successor-version:latest-version: 404

Something subtle that I suggested in #2795279-29: [PP-2] [META] Revisions support that I don't want to lose is the idea of "chained" rel negotiators. I gave the example of /jsonapi/node/article?resource_version=rel:predecessor-version:working-copy. You can see how those would work and be different in my example above. Perhaps we need a special delimiter for chained rels.

I don't want to lose that ^ but it doesn't need to be implemented here. We just shouldn't do anything to preclude it.

justageek’s picture

StatusFileSize
new25.08 KB

I have added fixes for the failing functional tests, I was not able to create the interdiff before I headed for the airport, my apologies.

justageek’s picture

Found and fixed issues with last patch, this is the latest.

gabesullice’s picture

X-posting this from IRC to the issue. This is a refinement of #44.

"working history":   __b___c__   __e___f___g___h
                    /         \ /
"version history": a           d

a->b is a "check out" in the spec
c->d is a "check in" in the spec
d->e is a "check out" in the spec

a->wasDefault()            === TRUE
d->isDefault()             === TRUE
a->isDefault()             === FALSE
(b,c)->wasDefault()        === FALSE
(e,f,g,h)->wasDefault()    === FALSE
h->isLatestVersion()       === TRUE
(all except h)->isLatestVersion() === FALSE

a === predecessor-version of (b,c,d,e,f,g,h)
d === latest-version of (all, including d)
d === successor-version of (a,b,c)
h === working-copy of (all, this is the only working-copy)
h === working-copy-of of (g)
g === working-copy-of of (f)
b === working-copy-of of (c)

The only missing link relation type is then successor-copy for forward navigation in a "working history" as there is successor-version for the "version history". I don't think that's terribly important though and we can live without it.

wim leers’s picture

How so ? I thought it was pretty linear. AFAIK, we don't store the "parent" revision of a given revision so we can't really build that graph. Unless I'm mistaken, revisions are only list.

Correct, we don't store a reference to the "parent" revision.

This isn't the first time you've said that, like @garphy, I'm a little confused though. I don't know that to be true, even with content_moderation installed. Could you explain how they're a DAG? AFAICT, revisions form a linear history. Certain revisions are marked "default" and with CM installed, the latest version can be in a different state. IOW, there's no concept of merging or forking.

Time to dig in and figure out if I'm misremembering correctly. It definitely sounds like that's the case.

I think I may be confusing what's currently implemented with what the Workflow Initiative plans to do. See #2786133: WI: Phase B: Extend the revision API with support for parents and specifically #2725523: Add a revision_parent field to revisionable entities. "nonlinear revisions" is explicitly mentioned in #2721129: Workflow Initiative for phase B. So yeah … I'm betting that's why I was convinced that revisions were a DAG, sorry!

I found beautiful confirmations of intended linearness in the UI improvement proposals at #1776796-94: Provide a better UX for creating, editing & managing draft revisions., even though that's 6 years old :P

So, yes, you guys are right, revisions are linear. But only in the sense that each revision is created at a certain time. Because if you restore an older revision, an older revisions suddenly reappears, even though it is not actually a successor to the revision that precedes it timewise. So our revisions are "linear but not really". Linear timewise, not linear contentwise. Of course, we lack the necessary metadata to track revision history *contentwise*. That's exactly what the Workflow Initiative aims to fix.

In doing that research, I also identified the following issues as impacting this feature at some point:

  1. #1812202-146: Add UUID support for entity revisions
  2. #2727511: WI: Add revision hash base field to all revisionable entities
  3. #2786133: WI: Phase B: Extend the revision API with support for parents

… since all of that is planned revision-related work in Drupal core

e0ipso’s picture

From what I see in the patch in #46 that @justageek merged #2992833: Add a version negotiation to revisionable resource types in here. I'll try to separate the two again.

e0ipso’s picture

Status: Needs work » Needs review
StatusFileSize
new4.96 KB
new38.97 KB

This fixes the tests from #36. More updates to come.

e0ipso’s picture

Something subtle that I suggested in #2795279-29: [META] Revisions support that I don't want to lose is the idea of "chained" rel negotiators. I gave the example of /jsonapi/node/article?resource_version=rel:predecessor-version:working-copy. You can see how those would work and be different in my example above. Perhaps we need a special delimiter for chained rels.

I'm not ignoring this. I'm just putting that thought on pause. I think we may need to define that feature a bit better. For instance you may want to request:

pipe(
  'rel:predecessor-version',
  'rel:working-copy-of',
  'id:17'
)

Note the cross-negotiators interaction. While it is unlikely that this level of flexibility will be useful and pragmatic, I think that with the current implementation (modular and pluggable) makes it "easy" to implement.

e0ipso’s picture

This patch changes the nomenclature according to #47. It also adds support for working-copy-of and predecessor-version but throws a cacheable 501 (Not Implemented) for now. We should address that in a follow up.

I still think that default revision == rel:latest-version and latest revision == rel:working-copy will be a huge source of confusion. However, at this point I'm willing to make this concession to bring this patch closer to merge.

wim leers’s picture

Status: Needs review » Needs work

RFC5829§3.5 even takes this into account specifically: "Some systems may allow more than one of these link relations in order to support branching."

Woah, fascinating! 👌

I agree with Mateu that it doesn't need to be in the patch, but I agree with you that we need to understand how it will work whether or not we implement it.

That's fine by me. Is @e0ipso also +1?


The working copy is the default revision.

I disagree. It's easy to confuse when latest-version and working-copy are the same thing though. So what's my reasoning?

A "working copy" is a resource at a server-defined URL that can be used to create a new version of a versioned resource."

New revisions always proceed from the tip of the version history. So, I think the "working-copy" is the revision with the highest revision ID.

OTOH, "the latest version is defined by the system." Which makes me think that latest-version should be the default version.

Wow, this is very very confusing!

It doesn't help that https://tools.ietf.org/html/rfc5829#section-3.2 says When included on a versioned resource, this link points to a resource containing the latest (e.g., current) version., while Drupal has both QueryInterface::currentRevision() and QueryInterface::latestRevision() and the docs for the latter say The latest revision is the most recent revision of an entity. This will be either the default revision, or a pending revision if one exists and it is newer than the default..
This means that also in the case of Drupal, just like in the case of RFC5829, latest is considered a level of indirection whose behavior follows certain rules (and could be customized).

As far as I can tell, that last emphasized statement (latest is default, or pending if newer than default) maps 1:1 to concepts in RFC5829. Especially if we factor in the concept of working-copy, which is something that cannot exist in "plain" Drupal, without Content Moderation. For working-copy to make sense, you at least need to distinguish between "draft" vs "live", which you cannot do in Drupal without Content Moderation.

With Content Moderation
Drupal concept RFC5829 concept
Default/current revision canonical
Pending revision working-copy
Latest revision latest-version
working-copy-of
Previous revision by ID, if it exists predecessor-version
Next revision by ID, if it exists successor-version
*If* there is a pending revision, then working-copy == latest-revision. If there is *no* pending revision, then working-copy == canonical.
Drupal has no direct equivalent for working-copy-of. This can only be reliably implemented once #2725523: Add a revision_parent field to revisionable entities is done. Because when you restore a prior revision in Drupal, it bears no relation at all to the revision directly preceding it in time or revision ID. Note that once this exists, predecessor-version and successor-version technically also need to change their behavior. Which is why I would err on the side of caution and omit those too (This is why I was saying #42 that you can't even reliably do previous/next links.)
Without Content Moderation
Drupal concept RFC5829 concept
Default/current revision canonical and latest-version
working-copy
working-copy-of
Previous revision by ID, if it exists predecessor-version
Next revision by ID, if it exists successor-version

The one thing that's in my proposal that hasn't been mentioned before on this issue, is the notion of using canonical to link to the default/current/live version.

I also think it's essential that latest-version always points to the actual latest, no matter if it's pending/draft (non-default) or default (current/live).

Looking forward to your feedback :)

e0ipso’s picture

That's a very different interpretation from what @gabesullice suggests. I honestly don't care what we end up deciding, although I have a slight preference. I only care about the feature being there. In other words, whatever interpretation we go with we should be able to load the revision we want with relations that point to current revision and latest revision.

However, I would love to get everyone on the same page relatively quickly if that is possible.

For the sake of discussion let's keep the 2 scenarios in #53 (with and without content moderation). And let's have in mind the revision history in #47.

Also, when we refer to revisions in Drupal lets talk about:

First revision
(regardless of moderation state). In our revision history: a
First default revision
(the first revision by revision ID that returns true to wasDefaultRevision). In our revision history: a
Previous revision (needs a revision anchor as reference)
(the previous revision when sorting by revision ID). In our revision history: Previous revision of f = e
Previous default revision (needs a revision anchor as reference)
(the previous revision when sorting by revision ID that returns true to wasDefaultRevision). In our revision history: Previous default revision of f = d
Next revision (needs a revision anchor as reference)
Analogous to previous revision
Next default revision (needs a revision anchor as reference)
Analogous to previous default revision
Current default revision
(returns true to isDefaultRevision). In our revision history: d
Last revision
(regardless of moderation state). In our revision history: h
Last default revision
(same as current default revision)

Maybe we can have a table of the relation types in RFC5829 according to both interpretations.

wim leers’s picture

I don't think it's very different. I think there is a significant risk of future BC breaks if we go with #44/#47.

Thanks for outlining the definitions. That's in line with what #47 says. But it results in two version histories. How do we plan to have two version-history exposed? The definition titles in #54 clearly show that there are two parallel version histories: there's "first/previous/next/last revision" and "first/previous/next/last DEFAULT revision", plus one special case: current default revision, which is effectively the live revision.

But RFC5829 clearly says:

A "version history" resource is a resource that contains all the versions of a particular versioned resource.

IOW: a single history.


I hope you're seeing that I'm concerned about going in a direction that will prevent us from exposing certain things in the future, because we go with a less-than-ideal mapping of RFC5829 concepts onto Drupal's implementation, which then means we can't improve that mapping in the future, which means we'll have to forever support this pattern.
I don't even have a strong opinion about any of this! I'm merely concerned.


Maybe we can have a table of the relation types in RFC5829 according to both interpretations.

I wanted to start doing this, but there are a few obstacles:

  1. #54 doesn't list pending revision, which is any non-default revision newer than the current default revision. This is a concept added by the Content Moderation module, but expressed using metadata present even without it installed. A consequence of this is that in #47's example, not only h is a working-copy … but for d, probably e, f, g and h are all working copies! (The spec literally says there can be multiple working copies.) I say "probably", because actually we don't know if e is a working copy started from d — it could also have been started from a, b or c. This is why we need #2725523: Add a revision_parent field to revisionable entities for working-copy-of.
  2. #47 and #54 make the IMHO dangerous assumption that each revision is based on the one preceding it from a time or ID perspective. But that is not at all guaranteed. Again, we don't know which revision e was started from. Which is why I don't think we can implement predecessor-version or successor-version. I would be okay with previous and next, because e.g. previous Refers to the previous resource in an ordered series of resources. Synonym for "prev".. We do have a single ordered list of all revisions, regardless of the actual predecessor version of each revision: based on revision ID we have an ordered listing, and hence previous and next are fine.

So, let me refine what I wrote in #53 (now there is no distinction between with/without Content Moderation anymore), and apply it to the diagram from #47:

"working history":   __b___c__   __e___f___g___h
                    /         \ /
"version history": a           d
Drupal concept RFC5829 concept #47 example revision ID
Current default revision canonical d
Latest revision latest-version h
Previous revision by ID, if it exists previous hg, …, ba
Next revision by ID, if it exists next ab, …, gh
Pending revision working-copy de, df, dg and dh.
Perhaps also ae, af, ag and ah?
Note that once #2725523 lands, we'd probably want to limit it to those "next" revisions whose parent traces back to a given revision, which is technically also a BC break, but a very soft one.
Finally: if we want to be truly cautious about BC breaks, we'll want to not add working-copy until after #2725523, unless we know what its upgrade path will look like and we can match that behavior. See #2725523-12: Add a revision_parent field to revisionable entities.
∅ (once #2725523, "parent revision(s)") working-copy-of (see concerns above: need to know parent)
∅ (once #2725523, "parent revision(s)") predecessor-version (see concerns above: need to know parent)
∅ (once #2725523, "parent revision(s)") successor-version (see concerns above: need to know parent)
If the latest revision also happens to be a default revision, then latest-version == canonical. If the latest revision is not a default revision, it must be a working copy, and hence latest-version == last working-copy. "latest" just always means "highest revision ID/highest revision timestamp".

P.S.: I left a comment at #2725523-10: Add a revision_parent field to revisionable entities to indicate that that is probably a blocker to this.

wim leers’s picture

Per #2725523-13: Add a revision_parent field to revisionable entities, we actually do know the strategy that will be used. Which means we can do working-copy, working-copy-of, predecessor-version and successor-version today!

That means that previous == predecessor-version and next == successor-version, until #2725523: Add a revision_parent field to revisionable entities lands. Once it lands, they'll start to diverge for any revision created after the site updates to a Drupal core version that contains #2725523, which means there will NOT be a BC break.

Drupal concept RFC5829 concept #47 example revision ID
Current default revision canonical d
Latest revision latest-version h
Previous revision by ID, if it exists previous hg, …, ba
Next revision by ID, if it exists next ab, …, gh
Pending revision working-copy de, df, dg and dh. i.e. all descendants
Perhaps also ae, af, ag and ah?
Note that once #2725523 lands, we'd probably want to limit it to those "next" revisions whose parent traces back to a given revision, which is technically also a BC break, but a very soft one.
Finally: if we want to be truly cautious about BC breaks, we'll want to not add working-copy until after #2725523, unless we know what its upgrade path will look like and we can match that behavior. See #2725523-12: Add a revision_parent field to revisionable entities.
Today: same as previous
After #2725523: Parent revisions
working-copy-of Today: inverse of working-copy
After #2725523: assuming e was created by reverting to c: ec eb, ea aka all ancestors.
Today: same as previous
After #2725523: First parent revision (i.e. mother/father, not grandmother/father)
predecessor-version Today: same as previous
After #2725523: assuming e was created by reverting to c: ec
Today: same as next
After #2725523, Child (not grandchild)
successor-version Today: same as next
After #2725523: assuming e was created by reverting to c: ce
If the latest revision also happens to be a default revision, then latest-version == canonical. If the latest revision is not a default revision, it must be a working copy, and hence latest-version == last working-copy. "latest" just always means "highest revision ID/highest revision timestamp".

gabesullice’s picture

I'm not ignoring this. I'm just putting that thought on pause. I think we may need to define that feature a bit better.

👍We're on the same page :)

I still think that default revision == rel:latest-version and latest revision == rel:working-copy will be a huge source of confusion.

I don't disagree with that. We'll need to communicate this well and consider the DX. A target attribute could help. I'm consoled by the fact that I think it's simple to make the point that rel:latest-version gets the latest "live" revision and rel:working-copy gets the latest draft revision. "draft" sounds a lot like "working-copy".

Wow, this is very very confusing!

I completely agree.

I think that a distinction that I made implicitly but should have called out is that I treat the word "version" to be distinct from and not synonymous with "revision". A "version" in the RFC language is a "revision" that was/is a default.

Especially if we factor in the concept of working-copy, which is something that cannot exist in "plain" Drupal, without Content Moderation. For working-copy to make sense, you at least need to distinguish between "draft" vs "live", which you cannot do in Drupal without Content Moderation.

All my examples presume the "draft" vs "live" distinction. Let's not forget that Content Moderation is stable in core.

I'd like to float the idea that JSON API revision support should require that CM be enabled. Or, at the very least, that without CM enabled we only provide a very spartan implementation, e.g. only having the id negotiator and a version-history collection, specifically excluding the X-version and working-copy links and the rel negotiator from use. I see very little practical benefit to this feature without CM installed. IOW, I think providing a content preview mechanism is far more valuable than a mechanism for displaying a revision log. And preview is only possible with CM enabled.

@e0ipso I think you'd agree with the relative priority there? ^

*If* there is a pending revision, then working-copy == latest-revision. If there is *no* pending revision, then working-copy == canonical.

I think this interpretation goes back to my assumption that version != revision. I think latest-version = isDefaultRevision() = TRUE and working-copy = isPendingRevision() = TRUE.

Why do I think version != revision? Let me pull some quotes:

A "checkin" is an operation on a working copy that creates a new version of its corresponding versioned resource.

When a versioned resource is checked out and then subsequently checked in, the version that was checked out becomes a "predecessor" of the version created by the checkin.

A "checkout" ... creates a working copy, or changes the versioned resource to be a working copy as well

A "checkin" is an operation on a working copy that creates a new version of its corresponding versioned resource.

Each of these examples seem to indicate that a "working copy" is not a version. Then what is it? It must be a draft. With CM, we have drafts. Drafts are pending revisions. All versions are revisions, but not all revisions are versions.

To call it out even more strongly: "A "checkin" is an operation on a working copy that creates a new version of its corresponding versioned resource."

"[A working copy's] corresponding versioned resource" really strongly indicates that a working copy is a derivative of a version and not a version itself.

If the latest revision also happens to be a default revision, then latest-version == canonical

I think it's highly informative that RFC 5829 makes no reference to canonical. To me, the need for the canonical link to identify d makes me a little skeptical of that interpretation. If it were necessary part, it would have been mentioned at least in contrast.

With my interpretation, we can identify every significant revision with only the link relation types mentioned. The ones we struggle with are prior revisions that were not default (b, c, e, f & g). These are the least significant in the system and would be identifiable with a parent-revision link relation type. I.e., we'd still need to have an link relation type outside the scope of the spec, but it's still very useful without it.


To summarize:

All revisions are not versions. If not, then which revisions are versions? Those that were/are default. What then is a revision that was/is not default? It is a revision that is/was a "working copy". What is a working-copy-of? The working copy's "corresponding versioned resource." IOW. the last revision that was a default revision in that revision's ancestry.

What is missing from that summary?

@Wim Leers and my own interpretations have both identified a need for forward and backward revision navigation and additional link relation types. I think the problem is that the spec was not written to account for multiple revisions of a draft. To fill that gap, I propose we have our own links then: parent-revision and child-revision in future. This is an additive addition that leaves a very functional implementation using only spec-defined relation types (without canonical #56 is not very functional).

What about version-history? I think it's entirely reasonable to include revisions that are not versioned in the version history. The "versioned" revisions will be identifiable by their links. For example, self will be equal to latest-version or self will be equal to working-copy. I don't think it's necessary to have separate collections for a "default" history and another collection for a different history. It can be a superset of just the official "versioned" revisions that includes "working copy" revisions.

e0ipso’s picture

👏🏽 @gabesullice. As I pointed out the other day in IRC what it makes more sense to me is the correspondence of "version" with "default revision" and "working copy" with "in-progress draft revision". I'm glad to see your articulation of it here.

I am ready to buy #57.

Random thoughts:

To put things in the language of the spec, a "check in" operation would be (∅ | Draft) ⇒ Published. A "check out" would be Published ⇒ (Archived | Draft). With Content Moderation we can create a new version by either checking out the version (hence making a working-copy) and then checking it back in (by publishing it). We can also create a new version by updating the content entity keeping it published (this workflow is also explicitly mentioned in the spec).

What about version-history? I think it's entirely reasonable to include revisions that are not versioned in the version history.

While I buy most of #57 I'm not ready for this. For me it brings down all the cohesiveness of the terminology. version-history is a history of versions. If versions are "default revisions" then I don't think it's entirely reasonable to include working copies there. Because working-copies != versions according to #57.


I think it makes sense to have a watered down versioning negotiation & discovery feature if CM is not enabled. However, I think that having our code paths split depending on other modules being enabled will be hard to maintain. If the VersionByRel was provided by Content Moderation (or we mocked that somehow in JSON API), I'd be open to that solution. In any case I'd like to see a very clear separation for the negotiation and linking.


FWIW the patch in #52 is compatible with the proposal in #57.

gabesullice’s picture

While I buy most of #57 I'm not ready for this. For me it brings down all the cohesiveness of the terminology. version-history is a history of versions.

I think I can convince you with one question: Is a draft not part of a version's history?

I think it makes sense to have a watered down versioning negotiation & discovery feature if CM is not enabled.

Fine with me.

However, I think that having our code paths split depending on other modules being enabled will be hard to maintain.

Agreed.

If the VersionByRel was provided by Content Moderation (or we mocked that somehow in JSON API), I'd be open to that solution.

I'm open to that too :) Let's see what it looks like. Perhaps we can put it under modules/content_moderation without an info.yml and force the plugin manager to discover it if the real content_moderation is enabled.

In any case I'd like to see a very clear separation for the negotiation and linking.

This x 💯. I was really pleased to see that you even made two separate issues for this :) I honestly can't articulate why this should be, but my spidey-sense tells me that it's important :P

wim leers’s picture

#57:

I think that a distinction that I made implicitly but should have called out is that I treat the word "version" to be distinct from and not synonymous with "revision". A "version" in the RFC language is a "revision" that was/is a default.

We had a call yesterday evening where we discussed our interpretations of RFC5829 and how its concepts map to Drupal. This was indeed a key thing that came up!

I think RFC5829 basically intends that the different working copies, which can be created off one another, do NOT have an accessible version history. They're "ephemeral" as far as the spec is concerned.

Or, at the very least, that without CM enabled we only provide a very spartan implementation, e.g. only […]I see very little practical benefit to this feature without CM installed.

I … am not sure I agree with that. Drupal has not had Content Moderation for >1.5 decades and got by just fine. I got stuck on the next step of what I was going to write. I tried using the revision functionality on a D8 site without Content Moderation, and concluded that this proposal of yours makes perfect sense :)

Each of these examples seem to indicate that a "working copy" is not a version.

Thanks for extracting those specific examples. Consider me convinced that this is indeed the intention of the RFC authors. a working copy is a derivative of a version and not a version itself. — if only the RFC authors had included this in their RFC! 😞 Would have saved us a lot of time!

I think it's highly informative that RFC 5829 makes no reference to canonical.

Fair.

With my interpretation, we can identify every significant revision with only the link relation types mentioned.

I'm still concerned that this means that it's going to be impossible to create a client that talks to JSON API and is able to provide a complete authoring experience, because without access to all revisions (Drupal terminology) or working copies (RFC5829 terminology), it cannot be considered complete.
This is in part why I never even would have considered that perhaps "working copies" are not "versions": because then how could the "version history" be considered complete?!

So that's my key question then: how do you propose we handle that?

To summarize:

All revisions are not versions. If not, then which revisions are versions? Those that were/are default. What then is a revision that was/is not default? It is a revision that is/was a "working copy". What is a working-copy-of? The working copy's "corresponding versioned resource." IOW. the last revision that was a default revision in that revision's ancestry.

👍 I think this correctly interprets the spec. My one concern still stands though.

EDIT: Perhaps I do still see one bit of ambiguity: does d have a working-copy relation to c or to e? I could see it argued either way… because the question is whether it means "this version originates from this working copy" or "there is a working copy initiated from this version"?)

parent-revision and child-revision

Did you mean version instead of revision?


#58:

While I buy most of #57 I'm not ready for this. For me it brings down all the cohesiveness of the terminology. version-history is a history of versions. If versions are "default revisions" then I don't think it's entirely reasonable to include working copies there. Because working-copies != versions according to #57.

Exactly this! ➕➕➕

I think it makes sense to have a watered down versioning negotiation & discovery feature if CM is not enabled. However, I think that having our code paths split depending on other modules being enabled will be hard to maintain.

Agreed with keeping "maintainability" very high in the list of priorities. I'm not yet sure that it'll necessarily be very complex though: to know that for certain, I'd need to see a patch that does it.


#59:

I think I can convince you with one question: Is a draft not part of a version's history?

But … version-history is intended to list all versions of a resource. And the spec defines "versions" to be different from "working copies". So to then include working copies in the version history seems contradictory.


AFAICT there are two remaining question:

  1. How can one access the history of working copies (draft revisions)? @gabesullice proposed to list them in version-history but @e0ipso and I feel that this is contradictory/undermines cohesiveness.
  2. Does d have a working-copy relation to c or to e? I could see it argued either way… because the question is whether it means "this version originates from this working copy" or "there is a working copy initiated from this version"?)
wim leers’s picture

To really grok this, I think we need to understand the RFC authors' point of view better. Quoting RFC5829:

   Checkin

      A "checkin" is an operation on a working copy that creates a new
      version of its corresponding versioned resource.

      Note: the operations for putting a resource under version control
      and for checking in and checking out depend on the protocol in use
      and are beyond the scope of this document; see [CMIS], [RFC3253],
      and [JSR-283] for examples.

RFC3253 is WebDAV.

JSR-283 is the spec for the Java Content Repository, which contains https://docs.adobe.com/content/docs/en/spec/jcr/2.0/3_Repository_Model.h.... I'm 99% certain that RFC5829 was written not with multiple systems in mind, but only JCR. That is … a faux pas. But here we are. All terminology in RFC5829 is identical in here: check in, check out, predecessor, successor, version history — but it's only a subset: "working copy" is missing.

CMIS is the "Content Management Interoperability Services (CMIS)" 1.0 spec: https://docs.oasis-open.org/cmis/CMIS/v1.0/cmis-spec-v1.0.html — it contains a different subset more of the terminology: check in, check out, version history, working copy (no "predecessor" or "successor" this time). This spec dates back to 2010, was updated in 2011 with errata. And in 2015, a 1.1 version was released: https://docs.oasis-open.org/cmis/CMIS/v1.1/CMIS-v1.1.html.
Of specific interest to me is how they define "working copy" (given my second remaining question in #60). https://docs.oasis-open.org/cmis/CMIS/v1.0/cs01/cmis-spec-v1.0.html#_Toc... explains their "Version Series" concept:

2.1.9.1 Version Series

A version series for a Document object is a transitively closed collection of all Document objects that have been created from an original Document in the Repository. Each version series has a unique, system-assigned, and immutable version series ID.
The version series has transitive closure -- that is, if object B is a version of object A, and object C is a version of object B, then object C is also a version of object A.

Interestingly, https://docs.oasis-open.org/cmis/CMIS/v1.0/cs01/cmis-spec-v1.0.html#_Toc... says:

2.1.9.4.1 Checkout

A new version of a versionable Document object is created when the checkIn service is invoked on the Private Working copy (PWC) of this object
[…]
After a successful checkout operation is completed, and […], the effects on other Documents in the Version Series MUST be as follows:
- The repository MUST throw an exception if the checkout service is invoked on any Document in the Version Series. (I.e. there can only be one PWC for a version series at a time.)
- The value of the cmis:isVersionSeriesCheckedOut property MUST be TRUE.

[…]

2.1.9.4.4 Checkin

[…]

The effects of the checkIn service MUST be as follows for successful checkins:
- […]
- For all Documents in the Version Series:
o The value of the cmis:isVersionSeriesCheckedOut property MUST be FALSE.
o […]

This literally says there can only ever be ONE working copy per document/resource/entity! 😂

6.  Acknowledgments

   Thanks to the members of Content Management Interoperability Services
   (CMIS) Technical Committee (TC) at OASIS for the initial proposal,
   and to Jan Algermissen for feedback during IETF review.

Thanks, CMIS… 😭 … for imposing your limited world view on HTTP link relation RFCs that aim to be generic.


I dug into the CMIS and JSR-283 specs to figure out an answer to #60.2. In doing so, I essentially managed to answer both questions.

Everything indicates that RFC5829 was proposed by CMIS, and to achieve a semblance of broad market support, they also involved JCR authors. A quick search leads to https://en.wikipedia.org/wiki/Content_Management_Interoperability_Servic..., which points to for example its document-centricness. Which is a similar problem as page-centric CMSes. Seen in that light, it's understandable that they chose to KISS and have only a single working copy.

In conclusion, working-copy in RFC5829 == "private working copy" in CMIS1.0 == a single forward/pending revision in Drupal that all authors must edit at the same time. Except that in RFC5829, multiple of these are allowed, yet they're not allowed to show up in the version history. Seemingly because neither CMIS nor RFC5829 consider it important that working copies (drafts) are listable/navigatable, because they're ephemeral.

wim leers’s picture

Based on #61, I'd say it is impossible to perfectly map RFC5829 to Drupal's capabilities. We just have to make an informed choice.

gabesullice’s picture

Whoa! Thank you for going so deep! That's incredibly useful.

Note: I responded to different parts of your comments as I read them, but I think it's important to read my replies below all as a whole because they all inform one another.


EDIT: Perhaps I do still see one bit of ambiguity: does d have a working-copy relation to c or to e?

Neither?

I think d should have a link of type working-copy to h.

h should have a link of type working-copy-of to d.

Quoting myself: "I think the problem is that the spec was not written to account for multiple revisions of a draft." Which is why there should not be a working-copy link from d to e, because e is not the latest draft, h is.

Why do I know that d has a link of type working-copy to h and not of type working-copy-of? Because sections 3.3 and 3.4 has "when included on a versioned resource" for working-copy and "when included on a working copy" for working-copy-of. d is our "versioned resource" so it must have the working-copy link pointing to h.


Did you mean version instead of revision?

No. Very much because of what I just said above. Since d has a link to h. There would be no way to navigate to e, f or g. parent-revision and child-revision fill in the gaps.


version-history is intended to list all versions of a resource.... So to then include working copies in the version history seems contradictory.

Not quite. It says it's "a resource which contains all versions of a versioned resource".

I think it's okay if it contains all versions, but also contains working copies.

Is that a little clever? Yes. Is it in violation of the spec? No.

Are drafts or working copies a part of a version's history? Yes.

Is it less than perfect? Yes. Do we have another proposal that would be more intuitive? No.

Do I believe one exists? Not really, I think that's the best we can do.

Could someone come up with a more intuitive approach? If anyone could, it would be @e0ipso or @Wim Leers ;)


This literally says there can only ever be ONE working copy per document/resource/entity!

That makes sense. It confirms my earlier intuition: "I think the problem is that the spec was not written to account for multiple revisions of a draft."


Based on #61, I'd say it is impossible to perfectly map RFC5829 to Drupal's capabilities. We just have to make an informed choice.

I completely agree with this.

"To fill that gap [between RFC5829 and Drupal's capabilities], I propose we have our own [link relation types]: parent-revision and child-revision in future. This is an additive addition that leaves a very functional implementation using only spec-defined relation types."

If working-copy points from version d to the most recent pending revision h, that's the most useful interpretation.

Why? If you're viewing the default version d wouldn't you want an "edit" button to start from h? And from the edit page, wouldn't you want the "cancel" button to send you back to d?

IOW, since working-copy must point to "ONE working copy," it should point to the single most logical revision, the last one. It should skip previous revisions of that draft because they've been superseded (i.e. e, f & g are superseded by h).

That approach ensures that a spec-only implementation is still quite useful. It can be "enriched" to match Drupal's capability of "a history for working copies" by adding my proposed link relation types, parent-revision and child-revision to access e, f, & g from d or h.

gabesullice’s picture

I love the symmetry of our conclusions:

#60

AFAICT there are two remaining question:

  1. How can one access the history of working copies (draft revisions)? @gabesullice proposed to list them in version-history but @e0ipso and I feel that this is contradictory/undermines cohesiveness.
  2. Does d have a working-copy relation to c or to e? I could see it argued either way… because the question is whether it means "this version originates from this working copy" or "there is a working copy initiated from this version"?)

#57

What is missing from that summary?

@Wim Leers and my own interpretations have both identified a need for forward and backward revision navigation and additional link relation types. I think the problem is that the spec was not written to account for multiple revisions of a draft. To fill that gap, I propose we have our own links then: parent-revision and child-revision in future. This is an additive addition that leaves a very functional implementation using only spec-defined relation types (without canonical #56 is not very functional).

What about version-history? I think it's entirely reasonable to include revisions that are not versioned in the version history. The "versioned" revisions will be identifiable by their links. For example, self will be equal to latest-version or self will be equal to working-copy. I don't think it's necessary to have separate collections for a "default" history and another collection for a different history. It can be a superset of just the official "versioned" revisions that includes "working copy" revisions.

What that tells me is that we're on the right track :)


Hopefully #63 satisfactorily addressed #1 for you. While I'm still hopeful you can come up with something better for #2, I'm willing to settle for my own proposal ofc :P

e0ipso’s picture

Fair warning: I'm still reading through the last updates.

Does d have a working-copy relation to c or to e? I could see it argued either way… because the question is whether it means "this version originates from this working copy" or "there is a working copy initiated from this version"?)

I read the link relations as: The ${link-rel-type-name} link of the current resource is ${link-value}

Examples:

  • The canonical link of the current resource is https://…/1234
  • The latest-version link of the current resource is https://…/1234

working-copy reads well with that. working-copy-of not so much, but I think that the RFC definition helps in that regard. However the definition of working-copy of the spec seems to contradict this /sigh.

/me continues reading.

wim leers’s picture

I think parent-revision and child-revision would be pretty confusing, since there's then 3 concepts in link relations alone: "working copies", "versions" and "revisions". Only a Drupalist will be able to understand it.

Is that a little clever? Yes. Is it in violation of the spec? No.

Are drafts or working copies a part of a version's history? Yes.

Is it less than perfect? Yes. Do we have another proposal that would be more intuitive? No.

It's hard to argue with this after having written #62.

Why? If you're viewing the default version d wouldn't you want an "edit" button to start from h? And from the edit page, wouldn't you want the "cancel" button to send you back to d?

IOW, since working-copy must point to "ONE working copy," it should point to the single most logical revision, the last one.

Hard to argue with this too.

So … it's like I have to agree with Gabe's proposal for lack of better alternatives 😲😂 As far as I can see, it's the least contradicting and most usable mapping of RFC5829 to Drupal's entity revision system out of all possible mappings.


Eagerly awaiting @e0ipso's feedback! 🙂 Rooting for him to do better, but not sure that's possible.

gabesullice’s picture

#65

I read the link relations as: The ${link-rel-type-name} link of the current resource is ${link-value}

Perhaps we should all just use the "official" way of saying it:

A link can be viewed as a statement of the form "{context IRI} has a {relation type} resource at {target IRI}, which has {target attributes}".

So, your examples would be:

  • https://.../one-two-three-four has a canonical resource at https://.../1234
  • https://.../v/a has a latest-version resource at https://../v/d

So:

  • https://.../v/d has a working-copy resource at https://.../v/h
  • https://.../v/h has a working-copy-of resource at https://.../v/d

Also, props for truly using HTTPS everywhere :P


#66

I think parent-revision and

child-revision

would be pretty confusing, since there's then 3 concepts in link relations alone: "working copies", "versions" and "revisions". Only a Drupalist will be able to understand it.

This is true. What about prior-working-copy and subsequent-working-copy?

wim leers’s picture

What about prior-working-copy and subsequent-working-copy?

Works for me!

gabesullice’s picture

@Wim Leers, awesome!

FTR, @e0ipso has also voiced his approval of the HTTP API in various conversations.

Which means... I think we've got consensus on the HTTP API!

Now that we have that, here's my initial review of the patch. FWIW, this is actually the first time I've read it.


  1. +++ b/src/Controller/EntityResource.php
    @@ -99,14 +109,25 @@ class EntityResource {
    +  public function __construct(
    ...
    +    VersionNegotiationManager $version_negotiation_manager
    
    @@ -347,6 +368,13 @@ class EntityResource {
    +    // If the request is for the latest revision, toggle it on entity query.
    +    $resource_version_identifier = $request->query->get(JsonApiSpec::VERSION_QUERY_PARAMETER);
    +    $wants_latest_version = $this->wantsLatestRevisionOnCollection($resource_version_identifier);
    +    if ($wants_latest_version) {
    +      $query->latestRevision();
    +    }
    
    @@ -397,6 +428,36 @@ class EntityResource {
    +  protected function wantsLatestRevisionOnCollection($resource_version_identifier) {
    
    +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,120 @@
    +      // This is a collection request, there is nothing to enhance.
    +      || !isset($defaults[$resource_type->getEntityTypeId()])
    

    This adds a lot of complexity to EntityResource and requires us to inject yet another dependency into it.

    Instead of parsing the parameter in the controller, let's move this logic into ResourceVersionRouteEnhancer and do $defaults['wants_latest_versions'] = TRUE|FALSE in its enhance method.

    As you can see, we already have the logic in place to identify a collection route inside the enhance method because we're explicitly saying "there's nothing to do!"

  2. +++ b/src/Plugin/VersionNegotiation/VersionById.php
    @@ -0,0 +1,38 @@
    + * Defines a revision id implementation for entity revision id values.
    

    s/id/ID/g

  3. +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,127 @@
    + * Revision id implementation for the current or latest revisions.
    

    same.

  4. +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,127 @@
    +  const HEAD = 'working-copy';
    ...
    +  const CURRENT = 'latest-version';
    

    I think it's confusing that these constants have different names than their values and that those names don't map to any concepts in the HTTP API. IOW, I don't think it aids understanding.

    I'd rather that we have constants matching the terms we've agreed upon above with very verbose commentary on each of them explaining their meanings how each of them relates to Drupal's revisions concepts.

  5. +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,127 @@
    +        $this->checkVersionHistorySupport($storage);
    +        $message = sprintf('The link relation type %s is not implemented yet.');
    

    ❤️

  6. +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,127 @@
    +   * Checks if the storage supports listing all the revisions of an entity.
    ...
    +  protected function checkVersionHistorySupport(EntityStorageInterface $storage) {
    +    if (!method_exists($storage, 'revisionIds')) {
    

    Whoa! We really don't have a better way than method_exists?! I'm not surprised and not surprised at the same time.

  7. +++ b/src/ResourceType/ResourceTypeRepository.php
    @@ -175,6 +177,22 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
    +    $is_versionable_storage = is_subclass_of(
    +      $entity_type->getStorageClass(),
    +      RevisionableStorageInterface::class);
    +    return $is_versionable_storage && $entity_type->isRevisionable();
    

    Again, this seems really clunky. Is there no better way?

  8. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,120 @@
    +    try {
    +      /** @var \Drupal\jsonapi\Revisions\VersionNegotiationInterface $negotiator */
    +      $negotiator = $this->revisionNegotiatorManager->createInstance($negotiator_name);
    +      $resolved_revision = $negotiator->getRevision($entity, $input_data);
    +    }
    +    catch (\InvalidArgumentException $exception) {
    +      VersionNegotiationManager::throwBadRequestHttpException($resource_version_identifier);
    +    }
    +    catch (PluginException $exception) {
    +      VersionNegotiationManager::throwBadRequestHttpException($resource_version_identifier);
    +    }
    

    If we keep plugins, let's just make a getRevision($negotiator_name, $entity, $negotiator_value) on the plugin manager itself.

    That eliminates the need to have the clunky createInstance call and public statics for these exceptions.

  9. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,67 @@
    +/**
    + * Provides an Revision ID negoation plugin manager.
    ...
    + * @internal
    + */
    +class VersionNegotiationManager extends DefaultPluginManager {
    

    s/negoation/negotiation/

    ---

    Even though this is @internal, I still feel very uneasy about having a plugin system for version negotiators.

    It seems like it introduces a lot of boilerplate and infrastructure code when we don't actually have any identified need for the modularity that it provides.

    Getting rid of the plugin manager and making everything more static would reduce the size and scope of this patch quite a bit.

  10. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,67 @@
    +  /**
    +   * The separator between the negotiator name and the negotiation ID.
    +   *
    +   * @var string
    +   */
    +  const SEPARATOR = ':';
    

    I think this belongs on the ResourceVersionRouteEnhancer since that is what is actually exploding the query parameter.

  11. +++ b/tests/src/Kernel/Plugin/VersionNegotiation/VersionByIdTest.php
    @@ -0,0 +1,32 @@
    +class VersionByIdTest extends VersionNegotiationTestBase {
    
    +++ b/tests/src/Kernel/Plugin/VersionNegotiation/VersionByRelTest.php
    @@ -0,0 +1,44 @@
    +class VersionByRelTest extends VersionNegotiationTestBase {
    
    +++ b/tests/src/Kernel/Plugin/VersionNegotiation/VersionNegotiationTestBase.php
    @@ -0,0 +1,115 @@
    +abstract class VersionNegotiationTestBase extends JsonapiKernelTestBase {
    

    This definitely needs some Functional tests.

gabesullice’s picture

Version: 8.x-1.x-dev » 8.x-2.x-dev
StatusFileSize
new35.26 KB

I've rerolled the patch for the 2.x branch (I have not yet ported changes to the Functional tests).

I have not made any of the changes I asked for in #69.

gabesullice’s picture

StatusFileSize
new4.93 KB
new35.1 KB

This moves the version query parameter name constant from JsonApiSpec to ResourceVersionRouteEnhancer because it is not part of the spec.

gabesullice’s picture

StatusFileSize
new12.02 KB
new31.93 KB

This addresses #69.1 by moving the logic that determines if the collection should load latest revisions or not to the route enhancer.

The logic was also not consistent with our agreed upon definitions. I updated the logic to match those.

Finally, the pattern used to load the latest version could cause lots of unnecessary db loads when most under certain circumstances. The new logic ensures that no unnecessary db loads are made.

gabesullice’s picture

StatusFileSize
new5.94 KB
new31.93 KB

This addresses #69.2

gabesullice’s picture

StatusFileSize
new1.41 KB
new32.12 KB

This begins to address #69.9 by making it impossible for 3rd party modules to implement version negotiation plugins. I still haven't seen the argument for plugins over static code in this issue yet, but I'll wait to remove it for now since it would be a somewhat dramatic change of direction.

gabesullice’s picture

StatusFileSize
new3.06 KB
new32.2 KB

This addresses #69.4.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new4.74 KB
new32.98 KB

This addresses #69.8, making the route enhancer much smaller and easier to read. In doing this, I realized we should probably have a new exception for a "revision not found" so we can issue a 404 instead of a 400 bad request.

More tomorrow.

Status: Needs review » Needs work

The last submitted patch, 76: 2992833-76.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

e0ipso’s picture

I still haven't seen the argument for plugins over static code in this issue yet

We've held this conversation in other forums, but not here. I think that it boils down to extensibility. Eventually we want revision negotiators to be extensible. The best approach for now is to have locked down plugins (plugins with unsupported opt-in). When things WRT revisions are stable we remove the lock.

wim leers’s picture

Eventually we want revision negotiators to be extensible. The best approach for now is to have locked down plugins (plugins with unsupported opt-in). When things WRT revisions are stable we remove the lock.

This sounds reasonable.

Any reason not to do this, @gabesullice?

gabesullice’s picture

I still haven't seen the argument for plugins over static code in this issue yet

We've held this conversation in other forums, but not here. I think that it boils down to extensibility. Eventually we want revision negotiators to be extensible. The best approach for now is to have locked down plugins (plugins with unsupported opt-in). When things WRT revisions are stable we remove the lock.

Yep. And I wanted it to be documented :)

Personally, I feel like this is a good case for applying the yagni principle. But I'm not going to die on that hill. It's not that important.

I may experiment with a service collector vs. a plugin manager. That will likely result in much less code while providing the same future benefit.

gabesullice’s picture

StatusFileSize
new14.91 KB
new36.19 KB

Follows up on #76. I introduced two more articulate exceptions so we can better differentiate between a revision which is missing and version identifier which is malformed.

After doing that, I realized the patch had many duplicated exceptions for impossible states. For example, there were conditions asserting that the entity/storage is revisionable, but the code in question would never be executed if that weren't already true.

Finally, the cacheability of the exceptions was incorrect in a couple places. For example, when the resource type is not revisionable, the cache context should be url.path, not url.query_args:resource_version because the resource type differs per route, not by the version identifier.


I also realized that the patch that I rerolled from did not include some of @e0ipso's recent changes. I'll roll those in in a minute.

gabesullice’s picture

StatusFileSize
new2.21 KB
new36.18 KB

Just fixing a couple typos and readability things.

In #69.7 I questioned why the code used is_subclass_of. Now I understand why...

It's because that avoids calling $this->entityTypeManager->getStorage(), which would instantiate a storage class for no other reason than to do an instanceof check. IOW, the is_subclass_of way uses less memory and takes less time.

gabesullice’s picture

Okay, this rolls in @e0ipso's changes from #52.

There was some more exception clean-up in VersionByRel to be done as in #81 too.

I looked into #69.6, where the patch was doing if (!method_exists($storage, 'revisionIds')) {. I had hoped to find a cleaner pattern, but it seems that it's a Drupal core shortcoming. See #2986027: Deprecate NodeStorageInterface::revisionIds in favor of entity query. IOW, there isn't a better way to check for that method.

BUT, #2986027: Deprecate NodeStorageInterface::revisionIds in favor of entity query does provide a pattern that we can copy. Doing so will enable us to support the full range of link relation types for all revisionable entity types (the current code will only work for nodes).

Then, when #2986027 lands, we can remove that workaround in favor of the core solution.

That will be next.

wim leers’s picture

this is a good case for applying the yagni principle

With Workspaces in core, I actually think it's pretty clear that we are going to need it? (Or at least that the probability for needing it is very high.)

gabesullice’s picture

Assigned: Unassigned » gabesullice
Status: Needs work » Needs review
StatusFileSize
new15 KB
new44.12 KB

#84: Let's consider it settled by majority rule then. Like I said, I'm not gonna choose that hill to die on :P

I've implemented the remaining link rels. There's an extensive comment in the interdiff that's worth reading.

Setting to NR for tests, this is still a WIP though.

Status: Needs review » Needs work

The last submitted patch, 85: 2992833-85.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

StatusFileSize
new2.45 KB
new44.26 KB

Reworded that long comment.


Doing so made me realize something. Maybe we shouldn't support predecessor-version, workig-copy-of, prior-working-copy and subsequent-working-copy at in the VersionByRel negotiator.

My thinking was that this would be useful for navigating forward and backward through a version history, but that's what the version-history resource will be for. #2992836: Provide links to resource versions (entity revisions) will also make it possible to navigate forward and backward via hyperlinks.

Shall we remove support for those rels entirely? @e0ipso / @Wim Leers?

wim leers’s picture

I'm fine with going with a MVP in this issue.

gabesullice’s picture

StatusFileSize
new8.64 KB
new63.43 KB

Done.

FWIW, I don't think that it's even a feature we want to add later. I think it's just a needless feature altogether. Since by using a version history or hyperlink, you'll be able to get to all those revisions by using the VersionById negotiator. I did not restore the 501 response code for that reason.

e0ipso’s picture

I'm fine with going with a MVP in this issue as well.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new3.03 KB
new63.38 KB

Code standards.

Gonna work on tests next.

Status: Needs review » Needs work

The last submitted patch, 91: 2992833-90.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new7.26 KB

This should make the existing tests pass.

Next patch will add functional tests.

gabesullice’s picture

StatusFileSize
new63.95 KB

Heh, it would have been nicer if I had included the patch to test.

Heh, AND if the patch would apply.

gabesullice’s picture

StatusFileSize
new37.25 KB

That last patch did not include the latest on 2.x... rerolled.

gabesullice’s picture

StatusFileSize
new8.5 KB
new45.53 KB

Here is a suite of Functional tests. I've only tested Node locally. It does not yet cover content_moderation use cases.

Tests will not pass. I'll post the fixes gradually to better explain the change sets.

Status: Needs review » Needs work

The last submitted patch, 96: 2992833-96.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new45.54 KB

Rerolled onto the latest 2.x

Status: Needs review » Needs work

The last submitted patch, 98: 2992833-98.patch, failed testing. View results

gabesullice’s picture

+++ b/tests/src/Functional/ResourceTestBase.php
@@ -2594,6 +2595,131 @@ abstract class ResourceTestBase extends BrowserTestBase {
+    $expected_document['links']['self']['href'] = $working_copy_url->setAbsolute()->toString();
+    $expected_document['data']['links']['self']['href'] = $latest_url->setAbsolute()->toString();

While tests are running, let me call this decision out ^

This means that resource objects' self links will always link to their specific revision's URL. This ensures that the link functions like a permalink for that resource object.

OTOH, resource top-level objects' self link will always link to the URL requested.

For example, GET /jsonapi/node/article?resource_version=rel:latest-version will have a top-level self link as /jsonapi/node/article?resource_version=rel:latest-version and a resource object self link as /jsonapi/node/article?resource_version=id:2.

Notably, that also means that GET /jsonapi/node/article (no query parameter) will have a top-level object's self link as /jsonapi/node/article (no query parameter) and a resource object's self link as /jsonapi/node/article?resource_version=id:2.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.11 KB
new46.46 KB
Testing Drupal\Tests\jsonapi\Functional\NodeTest
.F......F                                                           9 / 9 (100%)

Time: 1.91 minutes, Memory: 4.00MB

There were 2 failures:

1) Drupal\Tests\jsonapi\Functional\NodeTest::testGetIndividual
Failed asserting that Array &0 (
    0 => 'user.permissions'
) is identical to Array &0 (
    0 => 'url.query_args:resource_version'
    1 => 'user.permissions'
).

/var/www/html/modules/contrib/jsonapi/tests/src/Functional/ResourceTestBase.php:698
/var/www/html/modules/contrib/jsonapi/tests/src/Functional/ResourceTestBase.php:932
/var/www/html/modules/contrib/jsonapi/tests/src/Functional/NodeTest.php:286

2) Drupal\Tests\jsonapi\Functional\NodeTest::testRevisions
Failed asserting that Array &0 (
    0 => 'user.permissions'
) is identical to Array &0 (
    0 => 'url.query_args:resource_version'
    1 => 'user.permissions'
).

This is the first failure to address.

Obviously, the cache needs to consider the resource version requested... for two reasons:

  1. To make sure that you don't receive the wrong version
  2. To make sure that access is respected between revisions. When content_moderation is enabled, that will be more important. For example, if rev 1 is published and rev 2 is unpublished, I shouldn't be able to request ?resource_version=id:1 get an access allowed, then request ?resource_version=id:2 and get access allowed if I'm not allowed to view unpublished content.

Status: Needs review » Needs work

The last submitted patch, 101: 2992833-101.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new852 bytes
new46.41 KB

Cool, next failure:

Testing Drupal\Tests\jsonapi\Functional\NodeTest
.F......E                                                           9 / 9 (100%)

Time: 1.98 minutes, Memory: 4.00MB

There was 1 error:

1) Drupal\Tests\jsonapi\Functional\NodeTest::testRevisions
Exception: Notice: Undefined index: node
Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer->enhance()() (Line: 109)

This is just a leftover from the 1.x version of this patch when we renamed the entity parameter.

Status: Needs review » Needs work

The last submitted patch, 103: 2992833-103.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new3.54 KB
new48.32 KB
Testing Drupal\Tests\jsonapi\Functional\NodeTest
.F......F                                                           9 / 9 (100%)

Time: 1.96 minutes, Memory: 4.00MB

There were 2 failures:

1) Drupal\Tests\jsonapi\Functional\NodeTest::testGetIndividual
// I'll fix this later.

2) Drupal\Tests\jsonapi\Functional\NodeTest::testRevisions
The 'errors' member was not as expected.
Failed asserting that Array &0 (
    0 => Array &1 (
        'detail' => 'The current user is not allowed to GET the selected resource. The 'access content' permission is required.'
        'links' => Array &2 (
            'info' => Array &3 (
                'href' => 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.4'
            )
            'via' => Array &4 (
                'href' => 'http://php-apache-jenkins-drupal8-contrib-patches-39733/subdirectory/jsonapi/node/camelids/3652083c-0b7b-4fb4-99a9-7e607f34797c'
            )
        )
        'source' => Array &5 (
            'pointer' => '/data'
        )
        'status' => 403
        'title' => 'Forbidden'
    )
) is identical to Array &0 (
    0 => Array &1 (
        'detail' => 'The current user is not allowed to GET the selected resource. The 'access content' permission is required.'
        'links' => Array &2 (
            'info' => Array &3 (
                'href' => 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.4'
            )
            'via' => Array &4 (
                'href' => 'http://php-apache-jenkins-drupal8-contrib-patches-39733/subdirectory/jsonapi/node/camelids/3652083c-0b7b-4fb4-99a9-7e607f34797c?resource_version=id%3A1'
            )
        )
        'source' => Array &5 (
            'pointer' => '/data'
        )
        'status' => 403
        'title' => 'Forbidden'
    )
).

This failure is caused by access denied errors that should have a via link that includes a resource version ID.

Status: Needs review » Needs work

The last submitted patch, 105: 2992833-105.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new532 bytes
new48.28 KB

Code standards.

Status: Needs review » Needs work

The last submitted patch, 107: 2992833-107.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new2.31 KB
new50.58 KB

And this patch makes sure that resource objects have the right links too.

Status: Needs review » Needs work

The last submitted patch, 109: 2992833-109.patch, failed testing. View results

gabesullice’s picture

Status: Needs review » Needs work

The last submitted patch, 111: 2992833-111.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1010 bytes
new51.02 KB

Whew, that messed things up :P

Easy fix. It was a copy/paste thing. Of course one can't call getRevisionId() on an entity which does not implement RevisionableInterface.

Status: Needs review » Needs work

The last submitted patch, 113: 2992833-113.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new6.43 KB
new49.74 KB

After going down the path of adding the "correct" self links to error and resource objects. I think that self links actually belong in a #2992836: Provide links to resource versions (entity revisions) too. They raise a new set of questions that could derail this issue and greatly expand the patch size.

Status: Needs review » Needs work

The last submitted patch, 115: 2992833-115.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.47 KB
new48.8 KB

Okay, the last patch succeeded except for new CS violations. This resolves those.

My next patches should start to make assertions for when content_moderation is installed.

Status: Needs review » Needs work

The last submitted patch, 117: 2992833-117.patch, failed testing. View results

wim leers’s picture

#115++

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new652 bytes
new48.8 KB

This should fix many of the errors like this one:

1) Drupal\Tests\jsonapi\Functional\UserTest::testRevisions
LogicException: Entity type user does not support revisions.
gabesullice’s picture

StatusFileSize
new1.98 KB
new48.9 KB

This makes the assertions more generic, ie, not Node specific.

I'm doing this to fix MediaTest, which will mean we have at least two revisionable entity types tested thus far.

The last submitted patch, 120: 2992833-120.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

Status: Needs review » Needs work

The last submitted patch, 121: 2992833-121.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.98 KB
new48.49 KB

Removing the unused use statement.

gabesullice’s picture

StatusFileSize
new7.63 KB
new55.35 KB

And now, here are the tests for revisions + content_moderation. They should pass for at least Node and Media.

The last submitted patch, 124: 2992833-124.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 125: 2992833-125.patch, failed testing. View results

gabesullice’s picture

StatusFileSize
new642 bytes

Correct interdiff for #124.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new726 bytes
new56.06 KB

Oh look, it's our old friend MessageTest.

gabesullice’s picture

StatusFileSize
new450 bytes
new56.5 KB

Easy fix for BlockTest.

The last submitted patch, 129: 2992833-129.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 130: 2992833-130.patch, failed testing. View results

wim leers’s picture

This is looking great!

Here's a complete patch review :)

  1. +++ b/src/Controller/EntityResource.php
    @@ -342,6 +344,11 @@ class EntityResource {
    +    if ($request->get('working_copies_requested', FALSE)) {
    

    Let's move this to a constant.

  2. +++ b/src/Controller/EntityResource.php
    @@ -342,6 +344,11 @@ class EntityResource {
    +      $query->latestRevision();
    
    @@ -1005,14 +1012,21 @@ class EntityResource {
    +    $ensure_latest_revision = function (RevisionableInterface $entity) use ($storage) {
    +      /* @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
    +      return $entity->isLatestRevision() ? $storage->loadRevision($storage->getLatestRevisionId($entity->id())) : $entity;
    +    };
    

    Why do we need both of these?

  3. +++ b/src/Controller/EntityResource.php
    @@ -1032,7 +1046,8 @@ class EntityResource {
    -    $access = $entity->access('view', NULL, TRUE);
    +    $access = AccessResult::neutral()->addCacheContexts(['url.query_args:resource_version']);
    +    $access = $access->orIf($entity->access('view', NULL, TRUE));
    

    This seems like the wrong place to be doing this?

  4. +++ b/src/Normalizer/Value/EntityNormalizerValue.php
    @@ -79,12 +80,11 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    -    $rasterized['links']['self']['href'] = $this->linkManager->getEntityLink(
    -      $rasterized['id'],
    -      $this->context['resource_type'],
    -      [],
    -      'individual'
    -    );
    +    $route_name = "jsonapi.{$this->context['resource_type']->getTypeName()}.individual";
    +    $parameters = ['entity' => $this->entity->uuid()];
    +    $generated_url = Url::fromRoute($route_name, $parameters)->setAbsolute()->toString(TRUE);
    +    $this->setCacheability(static::mergeCacheableDependencies([$this, $generated_url]));
    +    $rasterized['links']['self']['href'] = $generated_url->getGeneratedUrl();
    

    Why do we need to change this?

  5. +++ b/src/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValue.php
    @@ -109,7 +109,7 @@ class JsonApiDocumentTopLevelNormalizerValue implements ValueExtractorInterface,
           // Every JSON API document contains absolute URLs.
    -      $this->addCacheContexts(['url.site']);
    +      $this->addCacheContexts(['url.site', 'url.query_args:resource_version']);
    

    This does seem like the right place.

    Nit: the comment needs to be updated.

  6. +++ b/src/Plugin/VersionNegotiation/VersionById.php
    @@ -0,0 +1,38 @@
    + * @VersionNegotiation(
    + *   id = "id",
    + * )
    ...
    +  /**
    +   * The name of the negotiator.
    +   *
    +   * @var string
    +   */
    +  const NEGOTIATOR_NAME = 'id';
    
    +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,65 @@
    + * @VersionNegotiation(
    + *   id = "rel",
    + * )
    ...
    +  /**
    +   * The name of the negotiator.
    +   *
    +   * @var string
    +   */
    +  const NEGOTIATOR_NAME = 'rel';
    

    Duplication.

  7. +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,65 @@
    + * Revision ID implementation for the current or latest revisions.
    

    s/current/default/ ?

  8. +++ b/src/Plugin/VersionNegotiation/VersionByRel.php
    @@ -0,0 +1,65 @@
    +        // The already loaded revision will be the latest version by default.
    

    Let's add an @see that points to the place that backs up this statement.

  9. +++ b/src/ResourceType/ResourceTypeRepository.php
    @@ -91,8 +92,8 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
    -        $resource_types = array_merge($resource_types, array_map(function ($bundle) use ($entity_type_id) {
    -          $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
    +        $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
    +        $resource_types = array_merge($resource_types, array_map(function ($bundle) use ($entity_type) {
    

    I also noticed this while working on #3006270: Add ResourceTypeRepository::createResourceType() for easier JSON API Extras support and simpler code, but managed to resist making this change :P It's a good change, but if we do this … then we might as well fix it properly.

    The proper fix would be a few lines higher up: rather than doing $entity_type_ids = array_keys(…); just do $entity_types = …; — much simpler!

  10. +++ b/src/ResourceType/ResourceTypeRepository.php
    @@ -290,6 +292,20 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
    +    $is_versionable_storage = is_subclass_of($entity_type->getStorageClass(), RevisionableStorageInterface::class);
    +    return $is_versionable_storage && $entity_type->isRevisionable();
    

    Isn't that second check alone sufficient? I think it is. Checked core. It also only checks the latter.

    That means we can remove this helper method altogether and just call $entity_type->isRevisionable().

  11. +++ b/src/Revisions/Annotation/VersionNegotiation.php
    @@ -0,0 +1,27 @@
    + * Defines an revision ID negoriation annotation object.
    

    s/negoriation/negotiation/ :)

  12. +++ b/src/Revisions/Annotation/VersionNegotiation.php
    @@ -0,0 +1,27 @@
    +class VersionNegotiation extends Plugin {
    
    +++ b/src/Revisions/PluginNegotiationBase.php
    @@ -0,0 +1,117 @@
    + * Base implementation for revision negotiator plugins.
    

    s/revision/version/

  13. +++ b/src/Revisions/InvalidVersionIdentifierException.php
    @@ -0,0 +1,10 @@
    +namespace Drupal\jsonapi\Revisions;
    ...
    + * Used when a version ID is invalid.
    

    It's "revisions" in Drupal Entity land.

    It's "versions" in JSON API land.

    Let's explicitly document this in jsonapi.api.php.

  14. +++ b/src/Revisions/PluginNegotiationBase.php
    @@ -0,0 +1,117 @@
    +   * Constructs a Drupal\Component\Plugin\PluginBase object.
    

    c/p remnant

  15. +++ b/src/Revisions/PluginNegotiationBase.php
    @@ -0,0 +1,117 @@
    +   * Helper method that ensures that a version exists.
    ...
    +  protected static function ensureVersionFound($revision) {
    

    s/Found/Exists/

  16. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +  const RESOURCE_VERSION_PARAM_VALIDATOR = '/^[a-z]+[a-z_]*[a-z]+'
    +  . VersionNegotiationManager::SEPARATOR
    +  . '[a-zA-Z0-9\-]+('
    +  . VersionNegotiationManager::SEPARATOR
    +  . '[a-zA-Z0-9\-]+)*$/';
    

    I'd prefer to see this reuse \Drupal\jsonapi\JsonApiSpec::MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS and \Drupal\jsonapi\JsonApiSpec::MEMBER_NAME_INNER_ALLOWED_CHARACTERS instead. That makes the need for this strict validation clearer: for spec compliance.

  17. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +   * @param \Drupal\jsonapi\Revisions\VersionNegotiationManager $revision_id_negotiation_manager
    

    s/$revision_id/$version/

  18. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +   *   The negotiator plugin manager.
    

    The version negotiator …

  19. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +    if ($defaults[RouteObjectInterface::CONTROLLER_NAME] === Routes::CONTROLLER_SERVICE_NAME . ':getCollection') {
    +      $latest_version_identifier = VersionByRel::NEGOTIATOR_NAME . VersionNegotiationManager::SEPARATOR . VersionByRel::LATEST_VERSION;
    +      $working_copy_identifier = VersionByRel::NEGOTIATOR_NAME . VersionNegotiationManager::SEPARATOR . VersionByRel::WORKING_COPY;
    

    AFAICT this means we cannot "just" create new version negotiation plugins and have them work.

    Which is what I think you were getting at last week in chat: whether it's okay to hardcode certain things until we actually want to support version negotiation plugins. Correct?

    If correct, I think adding a @todo here would make sense. Something like:
    @todo If version negotiation plugins become an officially supported API in the future, this logic will need to be moved into the relevant plugin.

  20. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +      // Whether the collection to be loaded should include only working-copies,
    +      // which in Drupal terms, means that the latest revisions should be
    +      // loaded. If the version identifier was 'latest-version', nothing needs
    +      // to be done since that is the default behavior.
    

    IMHO this documentation belongs on the VersionByRel class.

  21. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +  protected static function validResourceVersion($resource_version) {
    

    s/valid/isValid/

  22. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,131 @@
    +    assert($result || $result === 0, 'Regex failed.');
    

    Why this assert()?

    Why not make this as simple as \Drupal\jsonapi\JsonApiSpec::isValidCustomQueryParameter()?

  23. +++ b/src/Revisions/VersionNegotiationInterface.php
    @@ -0,0 +1,35 @@
    + * Defines the common interface for all Revision ID negotiation classes.
    
    +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,117 @@
    + * Provides an Revision ID negotiation plugin manager.
    

    s/Revision ID/version/
    s/classes/plugins/

  24. +++ b/src/Revisions/VersionNegotiationInterface.php
    @@ -0,0 +1,35 @@
    + * @see \Drupal\jsonapi\Revisions\VersionNegotiation
    

    I think this meant to refer to the annotation class?

  25. +++ b/src/Revisions/VersionNegotiationInterface.php
    @@ -0,0 +1,35 @@
    +interface VersionNegotiationInterface {
    +
    +  /**
    +   * Gets the identified revision.
    

    This actually would be an even better place than jsonapi.api.php to document the distinction between and mapping of "revision" and "version".

  26. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,117 @@
    +  public function __construct(
    +    \Traversable $namespaces,
    +    CacheBackendInterface $cache_backend,
    +    ModuleHandlerInterface $module_handler
    +  ) {
    

    PHPStorm formatting, let's put this on a single line.

  27. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,117 @@
    +    // Only discover plugins provided by JSON API. This is an internal API.
    

    👍

  28. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,117 @@
    +    parent::__construct('Plugin/VersionNegotiation', $internal, $module_handler, 'Drupal\jsonapi\Revisions\VersionNegotiationInterface', 'Drupal\jsonapi\Revisions\Annotation\VersionNegotiation');
    

    Let's use FQCN::class.

  29. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -0,0 +1,117 @@
    +      /* @var \Drupal\jsonapi\Revisions\VersionNegotiationInterface $negotiator */
    

    Use assert() instead. Just as many LoC, same autocompletion-in-IDE consequences, but with an extra assurance :)

  30. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // Reload entity so that it has the new field. Some entity types are not
    +    // stored, hence they cannot be reloaded.
    +    if (!$entity = $this->entityStorage->loadUnchanged($this->entity->id())) {
    +      assert(FALSE);
    +      return;
    +    }
    

    These lines can be removed; MessageTest::testRevisions() already takes care of this.

    It never will be possible to test revisions on an entity type that cannot be stored: if it cannot be stored, there can also not be revisions!

  31. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    $star_trek_salutation = 'Live long and prosper.';
    +    $star_wars_salutation = 'May the force be with you.';
    

    😄👏

  32. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    $original_url = clone $url;
    +    $original_url->setOption('query', ['resource_version' => "id:$original_revision_id"]);
    +    $latest_url = clone $url;
    +    $latest_url->setOption('query', ['resource_version' => "id:$latest_revision_id"]);
    +    $latest_version_url = clone $url;
    +    $latest_version_url->setOption('query', ['resource_version' => 'rel:latest-version']);
    +    $working_copy_url = clone $url;
    +    $working_copy_url->setOption('query', ['resource_version' => 'rel:working-copy']);
    

    These are all 4 version (revision) selection options that this patch introduces and that this concrete use case provides. 👍

    Übernit: $original_url slightly confused me later on, I thought it meant "URL object prior to modifications". But it really means "original revision's URL". IMHO $revision_original_url and $revision_latest_url would be slightly clearer. And perhaps $rel_latest_version_url + $rel_working_copy_url.

  33. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // Ensure the normal URL returns the default revision, which is always the
    

    s/normal URL/canonical resource URL/

  34. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    /* @var \Drupal\Core\Entity\RevisionableInterface $entity */
    +    /* @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
    

    Nit: I think you can merge these comments: /** @var Interface1|Interface 2 $entity */

  35. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // Fetch the 'published' revision by using the `latest-version` link
    

    Why ' in one place and ` in the other?

  36. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // @todo: determine if this is the correct field value for revision_translation_affected.
    +    $expected_document['data']['attributes']['revision_translation_affected'] = NULL;
    ...
    +    // @todo: determine if this is the correct field value for revision_translation_affected.
    +    $expected_document['data']['attributes']['revision_translation_affected'] = TRUE;
    

    First: yes this is correct per https://www.drupal.org/node/2897586.

    Second: I personally believe this should not be in the HTTP API response, see #2933518: The semantics of the "revision_translation_affected" field are unclear to Decoupled Drupal developers (REST/JSON API/GraphQL) users, improve this.

  37. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // The `latest-version` link should *still* reference the same revision
    +    // since 'draft' is not a default revision.
    ...
    +    // Now, the `working-copy` link should reference the 'draft' revision. This
    +    // is significant because without content_moderation, the two responses
    +    // have still been the same. This asserts a 403 forbidden because the user
    +    // is not allowed to see unpublished content.
    

    Great to have explicit comments for these! 👏

  38. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2593,6 +2596,221 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    $expected_cache_tags = array_unique(array_merge($expected_cacheability->getCacheTags(), $entity->getCacheTags()));
    

    Please use Cache::mergeTags() instead.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new7.54 KB
new55.76 KB

Thanks for the epic review @Wim Leers! I'll address that soon.

Now, I'm trying to get BlockContentTest working. Testing cache tags is a little more complicated. The field we're revisioning is a text field and so, thus far, we've had to add the config:filter.format.plain_text cache tag to the expected cache tags.

BlockContentTest::getExpectedCacheTags() already adds that cache tag though.

Rather than coming up with a clever way around that, like using Cache::mergeTags(), let's just change the revisioned field to something simpler. Like a number field.

wim leers’s picture

i noticed that #134's testbot status indicator says: D8.6 Waiting for branch to pass — so I dove in to fix this, see #3006743-7: Follow-up for #2624770: EntityConverter service requires additional parameters since Drupal core 8.7.

gabesullice’s picture

StatusFileSize
new1.45 KB
new54.31 KB

In #130, we can see that MessageTest is still not passing.

That's because of a change that was introduced then not completely reverted. @Wim Leers pointed that out in #133.4.

Reverting that change fixes MessageTest.

The last submitted patch, 134: 2992833-134.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 136: 2992833-136.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new6.02 KB
new54.66 KB

Thanks for jumping on that @Wim Leers!

This should get BlockContentTest to pass.

wim leers’s picture

Woot, green!

gabesullice’s picture

StatusFileSize
new7.41 KB
new54.96 KB

Woot, green!

EDIT: RIP my patch number :P


1. Done.
2. $query->latestRevision() does not mean that the latest entities are loaded. It means that the latest revisions are queried. $query->execute() still returns an array of entity IDs which need to be loaded. Still, this remark was a good one because it helped me find a more efficient way to load the latest revisions: RevisionableStorageInterface::loadMultipleRevisions().
3. Why did I do it like that? EntityResource::getIndividual has this:

    $entity = static::getAccessCheckedEntity($entity);
    if ($entity instanceof EntityAccessDeniedHttpException) {
      throw $entity;
    }

When an exception is thrown, it does not get normalized by JsonApiDocumentTopLevelNormalizer. Therefore, the cache context you qouted in #133.5 will not apply. So, the exception needs to carry all the appropriate cacheability metadata and the only way to get cacheability metadata into that class is to pass it in via the $access_result parameter. I felt like this was reasonable, because what we're saying is that "access to the entity is specific to the revision loaded" and that's true. Access can depend on the loaded revision.
4. This was addressed in #140.
5. Actually, it's unnecessary because of #3 above. It was added when I was still trying to put self links in the document with resource_version query strings.
6. Yes? I think `id` is required for a plugin and we're using the constants elsewhere where we don't have loaded plugins.
7. Fixed.
8. That documentation is surprisingly well hidden lol. Anyways, done.
9. Fixed :) Good call.

I'll address the next items in another comment + interdiff to keep the change set at a reviewable size.

Status: Needs review » Needs work

The last submitted patch, 142: 2992833-141.patch, failed testing. View results

wim leers’s picture

  1. ✔️
  2. 🤔 → 👍
  3. 🤔 Are you sure? Since #2949807: Spec Compliance: Error responses are missing the `jsonapi` top-level member error responses are generated using JsonApiDocumentTopLevel!
  4. ✔️
  5. 🤔 See #3.
  6. 🤔 Why can this not use getPluginId()?
  7. ✔️
  8. 👍
gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new4.99 KB
new57 KB

Let's fix those new failures, those came from me removing #133.4.

I also noticed that it is possible to use the resource_version on PATCH requests and POST requests on relationship routes. We shouldn't allow mutating arbitrary revisions. At least not until #2993557: Allow optional creation of new revision when PATCHing revisionable entities to support autosave functionality via JSON:API. is settled.

gabesullice’s picture

3. Ah, you're right. In JsonApiDocumentTopLevelNormalizerValue, there's this:

    if (!$this->isErrorDocument()) {
...
      // Every JSON API document contains absolute URLs.
      $this->addCacheContexts(['url.site']);
...
    }

I missed the isErrorDocument() check and forgot about #2949807. Because of that condition, I assumed what I said in #142.3 was the case.

I still feel like it's the wrong place to put that cache context though. It reminds me of #2992673: Set collection-specific query parameter cache contexts on collection responses instead of all responses where we tried to move things out of that top level class and put cache info in the most relevant place. If you feel very strongly in the opposite direction, I'll change it.

6. I'm still a little lost. Do you mean that getPluginId() should be overridden with return self::NEGOTIATOR_NAME;? The reason we need those constants is because of #133.19.

gabesullice’s picture

StatusFileSize
new31.53 KB
new61.19 KB

10. You're right. Done. Thanks for doing the research.
11. Fixed.
12. Fixed.
13. Done.
14. Fixed.
15. Done.
16. But this doesn't have to do with spec compliance. This validates the value of a query param, as in, ?key=value. The spec does not have rules for that. Personally, I prefer that the validation be more restrictive rather than less.
17. Fixed.
18. Done.
19. Yes. Done.
20. Done.
21. Done.
22. This is just a habit I developed when iterating on a regex to determine if it is invalid or if it's valid but simply didn't match anything. Cleaned up.
23. Done.
24. Fixed.
25. I added documentation in a few places, in addition to this one.
26. Done.
27. Yarr.
28. Good idea.
29. Nice suggestion! I like it.
30. Good point!
31. I know!!! I wish I could have kept those. See #134 for why they were lost :(
32. I like that. Good idea.
33. Fixed.
34. Much nicer.
35. I don't know. I ended up removing all quotes around 'published' and 'draft' since they're english language names, not machine names.
36. Wow, that's a gnarly issue... I agree that this field is confusing af.
37. :D! Hopefully you like all the comments even more!
38. Aye aye.


*Deep breath* WHEW! Epic review @Wim Leers. Thank you!

gabesullice’s picture

StatusFileSize
new3.77 KB
new61.17 KB
  1. +++ b/jsonapi.api.php
    @@ -71,6 +71,67 @@
    + *                   |    |
    

    Off by one.

  2. +++ b/jsonapi.api.php
    @@ -71,6 +71,67 @@
    + * latest-version @encode or the string @code latest-version @encode.
    

    s/latest-version/working-copy
    s/encode/endcode

  3. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -134,10 +132,8 @@ class ResourceVersionRouteEnhancer implements EnhancerInterface {
    +  protected static function isValidResourceVersionIdentifier($resource_version) {
    +    return preg_match(static::RESOURCE_VERSION_PARAM_VALIDATOR, $resource_version) === 1;
    

    Given the docs in jsonapi.api.php, these should both be "version identifier". I think renaming the constant might clear up #133.16 too.

  4. +++ b/src/Revisions/VersionNegotiationManager.php
    @@ -10,9 +10,10 @@ use Drupal\Core\Extension\ModuleHandlerInterface;
    + * Provides an version negotiation plugin manager.
    

    s/an/a


Next, this needs a few tests for the collection endpoint and possibly the related/relationship endpoints.

Also, I just discovered Drupal\node\Access\NodeRevisionAccessCheck... I'm not sure if that's respected already, but I think we need to ensure that it is. @e0ipso and @Wim Leers, do you agree?

wim leers’s picture

#145:

We shouldn't allow mutating arbitrary revisions.

Drupal's UI doesn't even allow editing any revision: editing a revision means creating a new revision; only the latest revision can be modified without creating a new revision: that just means appending changes to the latest revision. But editing arbitrary past revisions has never been supported.


#146:

  1. 🤔 I'm not 100% sure what's best here to be honest. But it seems fair to guarantee that it's always present on responses by having the logic in the document-level normalizer value object instead of in some function that happens to be called somewhere along the way.
  1. 🤔 My concern is that the annotation and constant are duplicating the same information. I'm saying I think the constant could be avoided by calling getPluginId() instead. If there's a reason we cannot call getPluginId(), then that is probably a valid justification for the constant to exist, but then we should document that justification. Let's at least mark these constants as @internal and add docs with a justification based on #133.19.

#147:

  1. 🤔 Why still keep the helper method?
  2. ✔️
  3. ✔️
  4. 👍
  5. ✔️
  6. ✔️
  7. 🤔 → 👍 Agreed with erring on the more restrictive side. I'd swear https://jsonapi.org/format/#query-parameters also covered URL query arg values, but it indeed does not. I'm fine with this then!
  8. ✔️
  9. ✔️
  10. 👍
  11. ✔️
  12. ✔️
  13. ✔️
  14. ✔️
  15. ✔️
  16. 👍 Note: this made me realize something: should it be VersionNegotiator instead of VersionNegotiation? I don't know.
  17. ✔️
  18. 😄
  19. ✔️
  20. ✔️
  21. 😄
  22. 👍→🤔 Note: you went with $rel_*_url and $*_revision_url. Would be clearer if it was $revision_*_url.
  23. ✔️
  24. ✔️
  25. Hehehe. :) Glad you reach the same conclusion independently. Yep, it's used for communicating internal state for some Field API internals. Not great. Definitely not API-First!
  26. I do!
  27. ✔️

Thanks especially for the jsonapi.api.php additions. I especially like the ASCII art! 👏 I still miss two things in it:

  1. An explicit mention that this is NOT yet part of the JSON API spec, but that a profile for version support is being worked on in https://github.com/json-api/json-api/issues/600.
  2. Support for listing all revisions is being worked on in

#148: Those changes look good. +1 for more tests. And yes, NodeRevisionAccessCheck MUST be respected, as must MediaRevisionAccessCheck. This sadly looks to not yet be standardized. I suspect that @amateescu has thoughts about this thanks to his work on #2725433: WI: Phase A: Use the revision API in more places, #2880149: Convert taxonomy terms to be revisionable etc. I'll ping him.

+++ b/jsonapi.api.php
@@ -71,6 +71,67 @@
+ * In future, other negotiators may be developed. For instance, a negotiator
+ * which is timestamp or workspace based.

s/In future/In the future/

s/timestamp or workspace based/timestamp or workspace-based/ (I think — I'm not 100% clear on the rules in English :))

wim leers’s picture

Pinged @amateescu via #2725433-42: WI: Phase A: Use the revision API in more places:

@amateescu: We're working on revision support in JSON API in #2992833: Add a version negotiation to revisionable resource types. We just discovered that (Node|Media)RevisionAccessCheck seem to be sort of "on their own": it appears there's no standardized Entity API infrastructure for dealing with revision access. Is that correct? Any input you would have on that subject would be super welcome. See #2992833-150: Add a version negotiation to revisionable resource types. Thanks! 🙏

amateescu’s picture

And yes, NodeRevisionAccessCheck MUST be respected, as must MediaRevisionAccessCheck. This sadly looks to not yet be standardized.

We're adding a generic entity revision access checker in #2350939: Implement a generic revision UI, but, looking at that patch, it doesn't seem to be very close to completion. I think it would benefit the most from a sister issue of #2809177: Introduce entity permission providers that would add generic revision permissions.

OTOH, after reading NodeRevisionAccessCheck it seems that it's quite focused on providing access to the "revisions overview" and "revert revision" forms, which is not very useful for JSON API I think.

The only conclusion that I have so far is that generic revision handling is quite a mess still :/ TBH, I'm not very familiar with everything that's going on in this area because I've been focused only on the storage layer so far, so I can't really provide any meaningful guidance on the access part here..

wim leers’s picture

The only conclusion that I have so far is that generic revision handling is quite a mess still :/

:(

TBH, I'm not very familiar with everything that's going on in this area because I've been focused only on the storage layer so far, so I can't really provide any meaningful guidance on the access part here..

This is good to know and helps a lot!

Thank you! :)

gabesullice’s picture

StatusFileSize
new793 bytes
new61.36 KB

Given that there's no standard way to check revision access yet. I've whitelisted the entity types for which we'll support revisions until that exists.

Next, I'm gonna implement the Node and Media access logic.

wim leers’s picture

That seems like a fair compromise to be able to move forward!

gabesullice’s picture

StatusFileSize
new900 bytes
new61.38 KB
gabesullice’s picture

StatusFileSize
new11.8 KB
new70.97 KB

EntityResource::getAccessCheckedEntity has the nice property that it is a central place to handle access checking. Get it right once, and it's right for everything. I don't want to lose that property.

Unfortunately, checking revisions isn't a small piece of code and will require injecting services... conditionally. That's because the revision checking services for node and media will only exist if/when node and media are enabled.

I've been working hard to reduce the size and scope of the EntityResource class and adding revision access checking to it will add yet more complexity. I'd prefer not to do that.

So, let's move EntityResource::getAccessCheckedEntity into its own service.

This makes sense really, we're already accessing it from multiple locations in the codebase. That's why it's public and static as it stands.

That's what this change does. We can move this to a separate issue if @e0ipso or @Wim Leers feel that it's warranted.

gabesullice’s picture

Assigned: gabesullice » Unassigned
StatusFileSize
new8.81 KB
new76.46 KB

Now, we can layer on revision access checking.

This will fail. I'm struggling with cache contexts and the AccessResult::$reason. It seems I can't have both correct at the same time 😭

I'm sure it's possible to get right, maybe @Wim Leers can take a look since it's my EoD.

Status: Needs review » Needs work

The last submitted patch, 157: 2992833-157.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new1.22 KB
new76.9 KB

This makes sense really, we're already accessing it from multiple locations in the codebase. That's why it's public and static as it stands.

+1 to making it a separate service. This is exactly the sort of "refactoring when it makes sense" that we ought to do continuously. Kudos for setting the right example!


This makes #157 get past that failing 403 due to different error messages. It now fails a few dozen lines later. The reason: this test is not yet granting the necessary revision-level permissions. Since you probably have a clear vision in your head of how this test coverage should work, I'm leaving that to you.

I think you were getting tricked by the fact that not having the access content permission for Node results in a AccessResult::forbidden(), which always takes precedence over AccessResult::neutral(), and hence also its reason takes precedence.

wim leers’s picture

StatusFileSize
new4.71 KB
new77.11 KB
  1. +++ b/src/Access/EntityAccessChecker.php
    @@ -0,0 +1,179 @@
    + * Checks access to entities.
    ...
    +final class EntityAccessChecker {
    ...
    +  public function getAccessCheckedEntity(EntityInterface $entity, AccountInterface $account = NULL) {
    ...
    +    $access = $entity->access('view', NULL, TRUE);
    ...
    +      $label_access = $entity->access('view label', NULL, TRUE);
    ...
    +  protected function checkRevisionAccess(EntityInterface $entity, AccountInterface $account, $operation) {
    ...
    +    assert($operation === 'view', 'JSON API does not yet support mutable operations on revisions.');
    

    This class and service don't indicate it, nor does the original EntityResource::getAccessCheckedEntity(), but … all of this is specifically for viewing entities.

    Which is why it's rather odd (and premature) to have a $operation parameter in the revision access check logic.

    I modified the revision access check method to omit this parameter (fixing one of the CS violations in the process) to keep this solely about "view" access. If you want to generalize this to check non-view access, then let's do that when the need arises.

  2. Fixed all my nits.
  3. #157: woah, that is super complex. We first check entity view access, then we check entity revision view access if it's revisionable and OR it, and then we check entity label view access if the combined result so far does not allow access. Of course things then become pretty complex!

    The thing is, like @amateescu indicated in #151, this is pretty much new territory. The access model for revisions is not very well defined. The closest thing to a formal definition we have is \Drupal\Tests\node\Functional\NodeRevisionPermissionsTest::testNodeRevisionAccessAnyType(), which was created in 2012 in #880940: view/revert/delete revisions per content type — and that test coverage leaves a lot of (clarity) to be desired.

    The safest thing to do here, is to AND the access checks together: to be able to view a revision of an entity, you have to be allowed to view the entity at all (the current patch already does this). But merely being allowed to view the labels of an entity does not entitle you to be allowed to view the label of any revision of that entity type — it only allows you to view the label of the default revision.

  4. #159 will fail on line 2716, for the two reasons cited in the previous point:
    1. The test coverage is not yet granting the necessary "view revisions" permissions.
    2. The logic is still allowing the view label operation to override the access results of both the "view entity" and "view entity revision" checks, and is hence resulting in a 200 response, but with only the entity label. (At least for Node, which is the entity type I was testing with.) view label should only run for the default revision.

The last submitted patch, 159: 2992833-159.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 160: 2992833-160.patch, failed testing. View results

gabesullice’s picture

Assigned: Unassigned » gabesullice
Status: Needs work » Needs review
StatusFileSize
new705 bytes
new76.86 KB

Fixing the CS violation and picking up where @Wim Leers left off... thanks @Wim Leers!

Status: Needs review » Needs work

The last submitted patch, 163: 2992833-163.patch, failed testing. View results

gabesullice’s picture

Assigned: gabesullice » Unassigned
Status: Needs work » Needs review
StatusFileSize
new6.18 KB
new78.1 KB

Yeah... Complex is a good word. I think I fixed it. 🤞

Status: Needs review » Needs work

The last submitted patch, 165: 2992833-165.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.3 KB

Well, I fixed the old "it". But I guess there's a new "it" to fix. Collections that aren't on revisionable resource types should get the url.query_args:resource_version cache context.

/me thinks now he's done it.

https://media.giphy.com/media/ebITvSXYKNvRm/giphy.gif

gabesullice’s picture

StatusFileSize
new78.87 KB

🙈. It's obviously time to sign off.

Status: Needs review » Needs work

The last submitted patch, 168: 2992833-167.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.64 KB
new79.51 KB

Couldn't resist.

Status: Needs review » Needs work

The last submitted patch, 170: 2992833-170.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new1.52 KB
new81.44 KB

Status: Needs review » Needs work

The last submitted patch, 172: 2992833-172.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new2.6 KB
new82.99 KB
gabesullice’s picture

WOOT! @Wim Leers++

Thanks for jumping in!


What's left?

#148:

Next, this needs a few tests for the collection endpoint and possibly the related/relationship endpoints.

#149

Let's at least mark these constants as @internal and add docs with a justification based on #133.19.

Note: this made me realize something: should it be VersionNegotiator instead of VersionNegotiation? I don't know.

Neither is incorrect. But I think VersionNegotiator is better. Will change.

🤔 Why still keep the helper method?

Because I like them. I think they improve readability :P

An explicit mention that this is NOT yet part of the JSON API spec, but that a profile for version support is being worked on in https://github.com/json-api/json-api/issues/600.

Support for listing all revisions is being worked on in

I'm not sure what you mean by this @Wim Leers ^

s/In future/In the future/

s/timestamp or workspace based/timestamp or workspace-based/ (I think — I'm not 100% clear on the rules in English :))

gabesullice’s picture

I just had this thought: Shouldn't #2942907: Entity system does not provide an API for retrieving an entity variant that is safe for editing inform what revision ID should be considered the "working copy"?

If not, why not?

gabesullice’s picture

StatusFileSize
new21.74 KB
new80.98 KB

This marks the constants as internal and renames the plugins and annotations to VersionNegotiator.

Status: Needs review » Needs work

The last submitted patch, 177: 2992833-177.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

#175:

Because I like them. I think they improve readability :P

+1 to that in general, but if it's just calling another isSomething() method, that's rather pointless. However, for now you have to hardcode the 3 supported entity types. So yes, then it totally makes sense to keep this helper method.

I'm not sure what you mean by this @Wim Leers ^

I think that's because perhaps you didn't see the bit just before those two points: Thanks especially for the jsonapi.api.php additions. I especially like the ASCII art! 👏 I still miss two things in it: […]. IOW: I think it'd be good to mention those two things in jsonapi.api.php too.


#176: excellent point and discovery! #2993557: Allow optional creation of new revision when PATCHing revisionable entities to support autosave functionality via JSON:API. previously linked to it. Thanks for pointing to it again! It's a critical core bug. Which makes me hopeful that it will get dealt with shortly, and whatever logic core decides JSON API can then provide a "forward compatibility shim" for.


#177's interdiff looks splendid 👏

amateescu’s picture

Re #176, that's exactly the goal of that issue :)

gabesullice’s picture

Support for listing all revisions is being worked on in

No, I didn't miss the first part. I don't understand that sentence. Maybe you mean a link to an issue should be added to jsonapi.api.php?

+1 to that in general, but if it's just calling another isSomething() method, that's rather pointless.

Ohhh. For some reason I had it in my head that you were talking about this:

+  protected static function isValidVersionIdentifier($resource_version) {
+    return preg_match(static::VERSION_IDENTIFIER_VALIDATOR, $resource_version) === 1;
+  }

Not isVersionableResourceType().

That makes my response seem much more snarky 🤭

You're right that that method was rather pointless, even for readability.

FWIW, I originally put that there assuming Extras would
eventually want to override it at some point.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.24 KB
new80.86 KB

Fixing the new CS violation.

gabesullice’s picture

StatusFileSize
new1.35 KB
new81.54 KB

Status: Needs review » Needs work

The last submitted patch, 183: 2992833-183.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new639 bytes
new11.35 KB
new85.24 KB

Gah. Silly CS violation.

This adds tests for collections.

gabesullice’s picture

StatusFileSize
new6.3 KB
new88.88 KB

Here are some preliminary tests for relationship routes.

We'll need to update RelationshipFieldAccess to respect the new revision access logic. I'll do that next.

gabesullice’s picture

StatusFileSize
new5.85 KB
new89.69 KB

This consolidates entity access checking from RelationshipFieldAccess into the new entity access checker.

The last submitted patch, 187: 2992833-187.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 188: 2992833-188.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.5 KB
new3.19 KB
new90.96 KB
+++ b/tests/src/Functional/ResourceTestBase.php
@@ -2854,6 +2867,47 @@ abstract class ResourceTestBase extends BrowserTestBase {
+      $expected_document['links']['self']['href'] = $test_url->toString();

Expecting the self and related links to be relative to the requested resource_version turned out to be pretty challenging and expansive without doing something hacky. That's the same conclusion I reached in #115, but forgot about it. This patch removes this expectation.


The current challenge I'm facing is related to caching. In the second iteration of the relationship assertions, after the call to $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']), the first response for /jsonapi/node/camelids/{{uuid}} gets cached and then the request for /jsonapi/node/camelids/{{uuid}}?resource_version=id:1 results in a dynamic page cache "HIT", despite me confirming that the url.query_args:resource_version cache context is set on both requests.

Status: Needs review » Needs work

The last submitted patch, 191: 2992833-191.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new933 bytes
new91.01 KB

Here's a little fix for all these errors:

Failed asserting that Array &0 (
    0 => 'user.permissions'
) is identical to Array &0 (
    0 => 'url.query_args:resource_version'
    1 => 'user.permissions'
).

Status: Needs review » Needs work

The last submitted patch, 193: 2992833-193.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new6.03 KB
new92.45 KB

This should fix the cache HIT/MISS thing.

What was wrong?

When a route access requirement is added to a route (like relationship routes, which use RelationshipFieldAccess), RouteAccessResponseSubscriber (RARS) will take the route access result and add it as a cacheable dependency to the response (if it's a CacheableResponse).

However, DynamicPageCacheSubscriber (DPCS) has a higher priority than RARS, so when DPCS generates a cache key for the response, it doesn't take the cacheability metadata from the access result into account. Fortunately, route access runs independently of DPC and so access is always respected.

The solution is to ensure the the query parameter cache context is added not just to the access result, but also the response. This makes sense since the response can vary based on the loaded revision, regardless of access.

Status: Needs review » Needs work

The last submitted patch, 195: 2992833-195.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new716 bytes
new92.51 KB

One would think I'd have figured out to add this check by now!

wim leers’s picture

Looking great! What are the next steps here?

This definitely needs documentation and a CR. What else?
Since this is by far the most complex (and perhaps even "invasive") addition to the JSON API module that is not defined by the JSON API 1.0 spec, I think this would be a great candidate to apply #2955020: Spec Compliance: JSON API's profile/extention (Fancy Filters, Drupal sorting, Drupal pagination, relationship arity) needs to be explicitly communicated to. Perhaps that is the last thing that ought to happen?

gabesullice’s picture

We still need tests for related routes.

That should be pretty simple because they're so closely related to relationship routes. The complexity will lie in getting the test expectations to be correct.

Then, we need to ensure that our definition of which revision the "working copy" is will not be different than the revision that #2993557: Allow optional creation of new revision when PATCHing revisionable entities to support autosave functionality via JSON:API. will produce.

Finally, I agree with you, we should figure out how this will integrate with profiles. Then document + publish a CR.

gabesullice’s picture

This adds related tests, in 3 parts:

  1. First, we add the assertions themselves.
  2. Necessary for that, we need a method to get an expected related ResourceResponse. We already have a getExpectedRelatedResponses (plural) method, so I just refactored that into a singular and plural one.
  3. There were some miscellaneous missing/extra cache tags/contexts to clean up.

PS. I cannot wait for Gitlab so I don't have break apart and comment on interdiffs like this. It should just be automatic with my commits!

PPS. Comment 200!

Status: Needs review » Needs work

The last submitted patch, 200: 2992833-200.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.63 KB
new104.05 KB

Erg.

Status: Needs review » Needs work

The last submitted patch, 202: 2992833-202.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.85 KB
new104.5 KB

I think this should fix the last failure.

gabesullice’s picture

StatusFileSize
new17.98 KB
new101.3 KB

I said a while ago that I wanted to try a service collector vs plugin managers. It was a relatively quick change and got rid of a lot of boilerplate. I'm curious what you think, @Wim Leers and @e0ipso. Personally, I feel this is a cleaner approach (relative to its enormous already size, it doesn't look like much).

gabesullice’s picture

StatusFileSize
new17.2 KB

Same interdiff as #205, but I think it's easier to see the changes because files got moved.

Status: Needs review » Needs work

The last submitted patch, 205: 2992833-205.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new2.49 KB
new105.76 KB
new102.56 KB

In #199, I said:

we need to ensure that our definition of which revision the "working copy" is will not be different than the revision that #2993557: Allow optional creation of new revision when PATCHing revisionable entities to support autosave functionality via JSON:API. will produce.

I went ahead and dug into this and am fairly confident that we've done this correctly. The editable revision is the latest revision. What we may not have done right is get the intersection of translations and the latest revision correct, but I think we should figure that out in #2794431: [META] Formalize translations support once this lands.


Unfortunately (maybe?), while researching this I discovered yet another bit of access control logic that we missed. content_moderation dynamically adds a route requirement and route access check to moderated entities. The actual access logic is in Drupal\content_moderation\Access\LatestRevisionCheck.

Since it's at the routing layer, not the entity access layer, it was easy to miss and not reusable. That means I had to duplicate it into our code :\

Again, I think that that was the last bit of access control to handle, but I'm no longer confident that I'm not missing yet another obscure thing. So, before we commit this, I would like to have a thorough review from a Content Moderation maintainer. I'm going to reach out to see if I can't arrange that.

The last submitted patch, 208: 2992833-208-plugin-api.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 208: 2992833-208-service-collector.patch, failed testing. View results

gabesullice’s picture

So, before we commit this, I would like to have a thorough review from a Content Moderation maintainer. I'm going to reach out to see if I can't arrange that.

I've reached out to @timmillwood for this 🤞🤞🤞

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.72 KB
new11.18 KB
new101.39 KB
new105.72 KB

This should get tests green again.

In both patches, I needed to fix how permissions were granted after implementing the LatestRevisionCheck logic.

In the service collector patch, I just needed to rework the Kernel tests a bit because they were built with the expectation of a plugin based approach.

The last submitted patch, 212: 2992833-212-service-collector.patch, failed testing. View results

gabesullice’s picture

Just a fixup for a finicky testbot :P

wim leers’s picture

  • #200: 👍
  • #205: Hm … I like how this solves #133.6 :) I really wish Drupal had solid guidelines for when to use plugins vs tagged services. I've had this discussion multiple times before, and I don't remember the outcome — probably because there was no unambiguous answer. Quite a bit smaller patch alone is nearly enough reason to do this. One big concern: AFAICT this is no longer an private API, since nothing is preventing additional services from being picked up. (Yes, I'm always watching the API surface :))
  • #208: 😱🙈 This is a consequence of having a too big and too ambiguous API surface. Well, that, and the fact that entity revision access control is not standardized, so Content Moderation pretty much had to do this. I'm not really comfortable duplicating this logic. I'd much rather call the service by its name (access_check.latest_revision) and pass it a fake route. That way, any customizations by contrib modules would at least still be respected.
  • #211: 👍
wim leers’s picture

Ran into #1812202 again (previously in #48), while digging into Media initiative stuff. This time linking it properly. I wish we already had this.

gabesullice’s picture

StatusFileSize
new101.98 KB
gabesullice’s picture

One big concern: AFAICT this is no longer an private API, since nothing is preventing additional services from being picked up. (Yes, I'm always watching the API surface :))

+++ b/src/Revisions/VersionNegotiator.php
@@ -0,0 +1,109 @@
+  public function addVersionNegotiator(VersionNegotiatorInterface $version_negotiator, $negotiator_name) {
+    assert(strpos(get_class($version_negotiator), 'Drupal\jsonapi') === 0, 'Version negotiators are not a public API.');
+    $this->negotiators[$negotiator_name] = $version_negotiator;
+  }

This needs to s/jsonapi/jsonapi\\/, but I think you missed this assert?

wim leers’s picture

I did! Never mind in that case :)

Seems like there's one last thing we should do here:

Finally, I agree with you, we should figure out how this will integrate with profiles.

(Arguably we should do this for #2958554: Allow creation of file entities from binary data via JSON API requests too. But that one is relatively stand-alone, whereas this issue/feature has potential implications for just about every JSON:API response!)

gabesullice’s picture

StatusFileSize
new7.75 KB
new11.36 KB
new104.62 KB

The attached primarily does a few things:

  1. It renames all occurrences of "JSON API" to "JSON:API"
  2. It limits support to only nodes and media entities. This patch previously also handled block_content entities, but that was not safe, I just emulated the logic of node and media. I'd rather wait for a core API instead. @Wim Leers and I discussed this in person and I indicated that I was going to limit this to just nodes, but I had forgotten that media also has an access check available.
  3. It addresses #215.3 by calling the service with route objects instead of duplicating logic.
  4. It addresses #218 by adding a trailing backslash.

#219, I'm not sure why profiles needs to block this, if profiles are not sufficient for what we've done, then the 1.1 spec should be fixed, rather than vice versa.

So, I think I've done just about everything I can on this patch.

I'll work on docs and a change record.

Status: Needs review » Needs work

The last submitted patch, 220: 2992833-220.patch, failed testing. View results

gabesullice’s picture

gabesullice’s picture

gabesullice’s picture

Issue tags: -Needs change record
gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new3.6 KB
new105.75 KB

Fixes #220, I forgot that the access check was running per entity in each revisioned collection.

wim leers’s picture

Status: Needs review » Needs work

Interdiff review:

  1. +++ b/jsonapi.services.yml
    @@ -154,6 +154,7 @@ services:
    +      - [setLatestRevisionCheck, ['@?access_check.latest_revision']] # This is only injected when the service is available.
    
    +++ b/src/Access/EntityAccessChecker.php
    @@ -46,6 +45,15 @@ final class EntityAccessChecker {
    +  protected $latestRevisionCheck = NULL;
    
    @@ -70,6 +78,19 @@ final class EntityAccessChecker {
    +  public function setLatestRevisionCheck(LatestRevisionCheck $latest_revision_check) {
    

    I think we should document that this is a temporary measure, that eventually JSON:API shouldn't need to be explicitly aware of Content Moderation.

  2. +++ b/src/Access/EntityAccessChecker.php
    @@ -175,35 +196,20 @@ final class EntityAccessChecker {
    -      case 'block_content':
    -        assert($entity instanceof BlockContentInterface);
    -        // There is no existing core mechanism to determine block content
    -        // access. Therefore, emulate the rules that node and media share.
    -        $access = AccessResult::allowedIfHasPermission($account, 'administer blocks');
    -        $access = $access->andIf($entity->access('view', $account, TRUE));
    -        $access = $access->andIf(BlockContent::load($entity->id())->access('view', $account, TRUE));
    -        break;
    

    +1

  3. +++ b/src/Access/EntityAccessChecker.php
    @@ -175,35 +196,20 @@ final class EntityAccessChecker {
           default:
    -        $reason = 'Revisions for non-core entity types are not yet supported by JSON:API.';
    -        $access = AccessResult::forbidden($reason);
    -        assert(FALSE, $reason);
    +        $access = AccessResult::forbidden('Only node and media revisions are supported by JSON:API.');
    

    Let's link to the issue(s) linked by @amateescu around #150. That may result in some people contributing to making this happen, since the error would point them directly to the place where they can help make it happen! :)

  4. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -83,11 +84,23 @@ final class ResourceVersionRouteEnhancer implements EnhancerInterface {
    +    if ($has_version_param && !in_array($resource_type->getEntityTypeId(), ['node', 'media'])) {
    

    Shouldn't this just rely on $resource_type->isVersionable()?

Patch review:

+++ b/src/ResourceType/ResourceTypeRepository.php
@@ -295,6 +294,21 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
+    return in_array($entity_type->id(), ['node', 'media', 'block_content']);

block_content should be removed here too.

gabesullice’s picture

gabesullice’s picture

StatusFileSize
new6.22 KB
new107.42 KB

This should address everything in #226.

Status: Needs review » Needs work

The last submitted patch, 230: 2992833-230.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.04 KB
new107.43 KB

🙈

wim leers’s picture

+++ b/jsonapi.services.yml
@@ -162,6 +162,7 @@ services:
+      # This is a temporary measure. JSON:API should not need to be aware of the Content Moderation module.

+++ b/src/Access/EntityAccessChecker.php
@@ -64,7 +64,9 @@ final class EntityAccessChecker {
-   * This will be NULL unless the content_moderation module is installed.
+   * This will be NULL unless the content_moderation module is installed. This
+   * is a temporary measure. JSON:API should not need to be aware of the
+   * Content Moderation module.

Let's make this an explicit @todo so we cannot forget to refactor it away!

I'm fine with only having this comment with @todo in only one place rather than multiple.

wim leers’s picture

mkdir(): File exists

That looks like random failures. Retesting.

wim leers’s picture

Issue tags: +Workflow Initiative

Green! 😀

I did a thorough final review and performed manual testing. I found a bunch of nits, some things that I think would benefit from an explicit test and especially a bunch of suggestions to make the change record more helpful:

  1. +++ b/jsonapi.api.php
    @@ -71,6 +71,82 @@
    + * @section revisions Revisions
    ...
    + * Link Relation Types for Simple Version Navigation between Web Resources.
    ...
    + * A "version" in the JSON:API module is any revision that was previously, or is
    ...
    + *              version-identifier
    + *                    __|__
    + *                   /     \
    + * ?resource_version=foo:bar
    

    Nit: I think this section should be called "Resource versions" based on the text below it, based on the URL query parameter and based on the ResourceType::isVersionable() method.

  2. +++ b/src/Access/EntityAccessChecker.php
    @@ -0,0 +1,255 @@
    +final class EntityAccessChecker {
    ...
    +  protected $resourceTypeRepository;
    ...
    +  protected function checkRevisionViewAccess(EntityInterface $entity, AccountInterface $account) {
    

    Nit: when marking a class final, then the properties and methods might as well be private rather than protected?

  3. +++ b/src/Controller/EntityResource.php
    @@ -342,6 +354,11 @@ class EntityResource {
    +    // If the request is for the latest revision, toggle it on entity query.
    +    if ($request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE)) {
    

    This means it's possible to get the working copy for an entire collection; that's not yet explained in the change record.

  4. +++ b/src/Controller/EntityResource.php
    @@ -1004,51 +1025,25 @@ class EntityResource {
    +    $entities = $load_latest_revisions && $storage instanceof RevisionableStorageInterface
    

    Nit: I think we can remove && $storage instanceof RevisionableStorageInterface. That could lead to subtle bugs, because every content entity uses \Drupal\Core\Entity\ContentEntityStorageBase, which means this is true for all of them, even though not every content entity type is revisionable.

    So just respecting the boolean flag should be enough.

  5. +++ b/src/Revisions/NegotiatorBase.php
    @@ -0,0 +1,100 @@
    +abstract class NegotiatorBase implements VersionNegotiatorInterface {
    ...
    +   * Constructs a VersionNegotiator.
    

    Nit: mismatch.

  6. +++ b/src/Revisions/NegotiatorBase.php
    @@ -0,0 +1,100 @@
    +   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
    +   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
    

    We probably should copy/paste the descriptions for these from \Drupal\Core\Entity\EntityTypeManagerInterface::getStorage().

  7. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +        $message = sprintf('Collection resources only support the following resource version identifiers: %s', implode(', ', [
    +          $latest_version_identifier,
    +          $working_copy_identifier,
    +        ]));
    +        throw new CacheableBadRequestHttpException($cacheability, $message);
    

    I think it may be worth having the tests load COLLECTION_URL?resource_version=id:42 and confirm that they get this error response.

  8. +++ b/src/Revisions/VersionByRel.php
    @@ -0,0 +1,62 @@
    +class VersionByRel extends NegotiatorBase {
    ...
    +  const WORKING_COPY = 'working-copy';
    ...
    +  const LATEST_VERSION = 'latest-version';
    

    The change record says this only works if you have Content Moderation installed. I don't think that's true?

  9. +++ b/src/Revisions/VersionNegotiator.php
    @@ -0,0 +1,109 @@
    + * @see plugin_api
    

    This is no longer a plugin manager, so this should be removed.

  10. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2594,6 +2639,362 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    $field_storage = FieldStorageConfig::create([
    ...
    +    $field_config = FieldConfig::create([
    

    Übernit: these don't need to be assigned to variables.

  11. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2594,6 +2639,362 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // The `latest-version` link should *still* reference the same revision
    +    // since a draft is not a default revision.
    +    $actual_response = $this->request('GET', $rel_latest_version_url, $request_options);
    

    I think it'd be good to also explicitly test that URL?resource_version=rel:latest_version is equivalent to URL.

    And document that in the change record.

  12. Quoting #219:

    Seems like there's one last thing we should do here:

    Finally, I agree with you, we should figure out how this will integrate with profiles.

    (Arguably we should do this for #2958554: Allow creation of file entities from binary data via JSON API requests too. But that one is relatively stand-alone, whereas this issue/feature has potential implications for just about every JSON:API response!)

    And #220:

    #219, I'm not sure why profiles needs to block this, if profiles are not sufficient for what we've done, then the 1.1 spec should be fixed, rather than vice versa.

    I agree that that should not block this, but given this will be the profile with by far the most impact, I think it would be prudent and highly valuable to do so now, especially since the JSON:API team tagged version 1.1-RC1 of the spec: https://twitter.com/jsonapi/status/1069593631365959680.

ndobromirov’s picture

This is for patch #232...

I am not tracking the while history, but there is some regression in the patch against jsonapi + jsonapi_extras. Due to the creation interface change in jsonapi, jsonapi_extras is loosing fields mapping input due to position in the __construct interface and thus does not show any fields on the responses.

If we go with that direction, there should be a follow-up on the jsonapi_extras very soon :)...

gabesullice’s picture

@ndobromirov, I'm sorry, but I don't really understand what regressed. Can you clarify?

ndobromirov’s picture

This is how jsonapi_extras is creating resource types from the repository. As they are not overwriting the constructor, the base class one is invoked and for the place that you see here $field_mapping, in the jsonapi with this patch the signature is expecting $is_versionable, and the actual list fields falls back to empty list, due to a default argument :(.


// Create subclassed ResourceType object with the same parameters as the
    // parent implementation.
    $resource_type = new ConfigurableResourceType(
      $entity_type->id(),
      $bundle,
      $entity_type->getClass(),
      $entity_type->isInternal() || (bool) $resource_config->get('disabled'),
      static::isLocatableResourceType($entity_type, $bundle),
      TRUE,
      $field_mapping
    );

As there are no additional arguments - this is passing empty list for fields_mapping resulting in empty responses (no attributes in the objects...).

Either way jsonapi_extras will need a follow-up once this is in... Should I create one and patch the case I am trying to describe?

ndobromirov’s picture

Here is the issue I've mentioned. Patch is coming in a bit.


So this is now patched and I can confirm that it's fixing the issue.
gabesullice’s picture

StatusFileSize
new7.42 KB
new116 KB

#235:

1. Done.
2. What does that buy us other than making it harder to "unfinalize" this at a later point?
3. Will do.
4. I think I did this check for better code completion in the IDE rather than to protect against non-revisionable entities (that's already asserted long before this code runs). To make that more clear, I refactored it into an assert.
5. Sorta, it's an abstract class so the comment would be more correct for the extending class. I compromised with "Constructs a version negotiator instance".
6. Done.
7. Good idea, done.
8. Technically, you can request versions by their rel even when Content Moderation is not enabled, but it's really pointless to do so. Without CM installed, the latest revision is always both the latest revision (working copy) and the default revision (latest-version). IOW, URL, URL?resource_version=rel:latest-version and URL?resource_version=rel:working-copy all return the same thing. The change record doesn't say that it doesn't work, it just says that you can use rels if you have CM installed. I'll revise it a little bit.
9. Done.
10. Done.
11. Done.
12. I'll open an issue for it and start a draft.

gabesullice’s picture

@ndobromirov++

That makes sense. Thanks for testing this and for writing a patch! You're awesome!

Status: Needs review » Needs work

The last submitted patch, 240: 2992833-238.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

gabesullice’s picture

Status: Needs work » Needs review
Related issues: +#3020136: Author and register a JSON:API profile for resource versioning

Silly typo.


I updated the CR: https://www.drupal.org/node/3015434/revisions/view/11201527/11225931

I created an issue for an associated profile.

gabesullice’s picture

StatusFileSize
new1.34 KB
new116 KB

^ for #243

The last submitted patch, 71: 2992833-71.patch, failed testing. View results

The last submitted patch, 72: 2992833-72.patch, failed testing. View results

The last submitted patch, 73: 2992833-73.patch, failed testing. View results

The last submitted patch, 74: 2992833-74.patch, failed testing. View results

The last submitted patch, 75: 2992833-75.patch, failed testing. View results

The last submitted patch, 89: 2992833-89.patch, failed testing. View results

The last submitted patch, 87: 2992833-86.patch, failed testing. View results

The last submitted patch, 83: 2992833-83.patch, failed testing. View results

The last submitted patch, 82: 2992833-82.patch, failed testing. View results

The last submitted patch, 81: 2992833-81.patch, failed testing. View results

gabesullice’s picture

Status: Needs review » Needs work

When testing against Drupal 8.5:

> Drupal\Core\Composer\Composer::upgradePHPUnit
PHP Fatal error:  Trait 'Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait' not found in /var/www/html/modules/contrib/jsonapi/tests/src/Functional/ResourceTestBase.php on line 51
wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new4.39 KB
new119.37 KB

#240: That all looks great!

  1. Hah, excellent point!
  1. Yep, that was my point, sorry for not making it more clearly: it's confusing that all 3 work but they all return the same information.

The change record is now much clearer. 👍


Interdiff review:

  1. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2727,6 +2725,8 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    $rel_invalid_collection_url = clone $collection_url;
    +    $rel_invalid_collection_url->setOption('query', ['resource_version' => 'rel:invalid']);
    
    @@ -2843,6 +2843,9 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // Fetch the collection URL using an invalid version identifier.
    +    $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options);
    +    $this->assertResourceErrorResponse(400, 'Collection resources only support the following resource version identifiers: rel:latest-version, rel:working-copy', $rel_invalid_collection_url, $actual_response, FALSE, ['4xx-response' ,'http_response'], ['url.path', 'url.query_args:resource_version']);
    

    👍

  2. +++ b/tests/src/Functional/ResourceTestBase.php
    @@ -2866,6 +2869,15 @@ abstract class ResourceTestBase extends BrowserTestBase {
    +    // Ensure that the `latest-version` response is same as the default link,
    +    // aside from the document's `self` link.
    ...
    +    // And the same should be true for collections.
    

    👍


#255: I say we do just the same as we did in #3001958: 4 test fails due to using deprecated code on 8.6 and 8.7 since #2996789: temporarily fork the test trait for \Drupal\Tests\field\Traits\EntityReferenceTestTrait: that trait was also only available in >=8.6. Did that in this reroll. Also had to adjust the logic to not create the editorial workflow on 8.5, because it was created automatically then. Not pretty, but necessary to make this work on 8.5, and works fine locally for at least NodeTest::testRevisions()

wim leers’s picture

Assigned: Unassigned » e0ipso

That leaves only one thing: getting the @e0ipso's stamp of approval!

I just talked to him in chat. He needs to recheck the patch, but doubts he'll have many concerns.

There is one key concern he has though: he's not yet sure about the tagged-service-instead-of-plugin approach. That started in #205, in #214 there were green patches for both approaches. In #215 I expressed a slight preference for tagged services since it results in less code and less metadata duplication.
I personally don’t have a strong preference; the primary thing I care about is that we don’t allow new version negotiators, regardless of whether they’re services or plugins. Quoting myself: I really wish Drupal had solid guidelines for when to use plugins vs tagged services. I’ve had this discussion multiple times before, and I don’t remember the outcome — probably because there was no unambiguous answer.. I also pinged Plugin system maintainer @tim.plunkett for advice.

Let's figure this out!


I'm excited to see how far this has come. It looks like we'll be able to ship version 2.1 of JSON:API with both #2958554: Allow creation of file entities from binary data via JSON API requests and this at the same time :)

e0ipso’s picture

Partial review. Only minor nits and questions.

  1. +++ b/jsonapi.api.php
    @@ -71,6 +71,82 @@
    + * In doing so, JSON:API module should be maximally compatible with other
    + * systems and should minimize the "Drupalisms" that a developer building
    + * against a JSON:API implementation will be required to know.
    

    Let's remove this part:

    and should minimize the "Drupalisms" that a developer building against a JSON:API implementation will be required to know.

  2. +++ b/jsonapi.api.php
    @@ -71,6 +71,82 @@
    + * In future, other negotiators may be developed. For instance, a negotiator
    + * which is timestamp or workspace based.
    

    Can we mention version UUID here?

  3. +++ b/jsonapi.services.yml
    @@ -167,6 +181,7 @@ services:
    +      - '@jsonapi.entity_access_checker'
    

    This breaks JSON:API Extras, would you consider setter injection?

  4. +++ b/src/Access/EntityAccessChecker.php
    @@ -0,0 +1,255 @@
    +final class EntityAccessChecker {
    

    final seems like an unnecessary roadblock. What if this needs to be decorated to include access checks to custom entity types?

  5. +++ b/src/Access/EntityAccessChecker.php
    @@ -0,0 +1,255 @@
    +   * This will be NULL unless the content_moderation module is installed. This
    +   * is a temporary measure. JSON:API should not need to be aware of the
    +   * Content Moderation module.
    

    Temporary until when?

  6. +++ b/src/Access/EntityAccessChecker.php
    @@ -0,0 +1,255 @@
    +    $account = $account ?: \Drupal::currentUser();
    ...
    +    $entity_repository = \Drupal::service('entity.repository');
    

    Why not inject these as well?

wim leers’s picture

#258.3: Are you sure it breaks JSON:API Extras? As of #3004582: Make JSON API Extras 2.x (next release: 2.10) compatible with JSON API 2.0-beta2, JSON:API Extras' ConfigurableResourceTypeRepository no longer overrides the ResourceTypeRepository constructor, precisely to avoid breaking it whenever the base implementation's constructor is changed :)

e0ipso’s picture

+++ b/src/Access/EntityAccessChecker.php
@@ -0,0 +1,255 @@
+    $entity_type = $entity->getEntityType();
+    switch ($entity_type->id()) {
+      case 'node':
...
+      case 'media':
...
+      default:
+        $reason = 'Only node and media revisions are supported by JSON:API.';
+        $reason .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
+        $reason .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
+        $access = AccessResult::forbidden($reason);

What about custom & contrib entity types that define revision access checkers?

We should look into allowing them to be supported without requiring changes in JSON:API.

Maybe a factory is a good pattern to consider here.

e0ipso’s picture

#259 I misread that. It's not (like I thought) on the ResourceType (nor the repository), but in the EntityResource controller instead.

gabesullice’s picture

StatusFileSize
new3.67 KB
new109.29 KB

#258:

1. Done.
2. Sure!
3. n/a per #261
4. I _really_ don't want this to be decorated lol. Instead, I want a real core API for revision access checking and/or for custom access to be handled by decorating the appropriate entity access control handler. Until then, I figured it was prudent (from a security and BC perspective) and desirable (from a pragmatic/expedience perspective) to go with an MVP that only allows for Node and Media entities to be versioned this way.
5. Temporary until a core revision access API exists. That's documented in the code block in #260.
6. Good catch! This was taken out of EntityResource.php where it used to be a static method. I never updated it apparently.

#260:

See 4 & 5 above. If this is really important and in high demand, I'd like to do it in a followup.

Status: Needs review » Needs work

The last submitted patch, 262: 2992833-262.patch, failed testing. View results

e0ipso’s picture

Some more comments on the current patch. I'm sorry I can only do in-depth reviews in chunks. I can imagine how frustrating that may be. I apologize for delaying this.

  1. +++ b/src/ResourceType/ResourceType.php
    @@ -284,16 +301,19 @@ class ResourceType {
    +   * @param bool $is_versionable
    +   *   (optional) Whether the resource type is versionable.
    ...
    -  public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, array $field_mapping = []) {
    +  public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, $is_versionable = FALSE, array $field_mapping = []) {
    

    The ResourceType constructor was updated after all.

    This means that JSON:API Extras will be broken by this change.

    How can we reconcile that?

  2. +++ b/src/ResourceType/ResourceTypeRepository.php
    @@ -315,6 +314,21 @@ class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
    +  protected static function isVersionableResourceType(EntityTypeInterface $entity_type) {
    +    // @todo: remove the following line and uncomment the next one when revisions have standardized access control. For now, it is unsafe to support all revisionable entity types.
    +    return in_array($entity_type->id(), ['node', 'media']);
    +    /* return $entity_type->isRevisionable(); */
    +  }
    

    Again, this seems to be the biggest disconnect. I don't think Core will get this together in reasonable time.

    We should talk about this specifically. I am leaning towards detecting that an entity type has/lacks a revision access checker instead of manually hard-coding that feature.

  3. +++ b/src/Revisions/NegotiatorBase.php
    @@ -0,0 +1,102 @@
    +  protected static function ensureVersionExists($revision) {
    +    if (is_null($revision)) {
    +      throw new VersionNotFoundException();
    +    }
    

    Would NULL be coming from a valid version identifier? If not, is InvalidVersionIdentifier more appropriate here?

  4. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +    if ($has_version_param && !$resource_type->isVersionable()) {
    +      $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
    +      /* Uncomment the next line and remove the following one when https://www.drupal.org/project/drupal/issues/3002352 lands in core. */
    +      /* throw new CacheableHttpException($cacheability, 501, 'Resource versioning is not yet supported for this resource type.'); */
    +      $message = 'JSON:API does not yet support resource versioning for this resource type.';
    +      $message .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
    +      $message .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
    +      throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
    +    }
    +
    +    // If the resource type is versionable, then nothing needs to be enhanced.
    +    if (!$resource_type->isVersionable()) {
    +      // However, if the query parameter was provided, provide a helpful error.
    +      if ($has_version_param) {
    +        throw new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]), 'A specific version of an unversionable resource was requested.');
    +      }
    +      return $defaults;
    +    }
    

    I think line 107 is dead code, it would've thrown in line 100.

  5. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +      $defaults['entity']->addCacheContexts([static::CACHE_CONTEXT]);
    

    Those cache contexts do not really belong to the entity. This seems to be a way to accumulate cacheable information.

    Would it be more appropriate to new CacheableMetadata() and then merge the entity and add the static::CACHE_CONTEXT?

  6. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +    // If no version was specified, nothing is left to enhance.
    +    if (!$has_version_param) {
    +      return $defaults;
    +    }
    

    What's the reason to have this here instead of at the top of the method?

  7. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +    // Provide a helpful error when a version is specified with a mutation
    +    // request.
    +    if (!in_array($request->getMethod(), ['GET', 'HEAD'])) {
    +      throw new BadRequestHttpException(sprintf('%s requests with a `%s` query parameter are not supported.', $request->getMethod(), static::RESOURCE_VERSION_QUERY_PARAMETER));
    +    }
    

    Maybe we can change the language to unsafe methods, as opposed to mutation.

    Also, will this affect pre-flight requests using an OPTIONS method? I always forget how that external library works.

  8. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +      $cacheability = (new CacheableMetadata())->addCacheContexts([static::CACHE_CONTEXT]);
    

    We are not adding the url.path cache context here. Do we need to?

  9. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +    if ($defaults[RouteObjectInterface::CONTROLLER_NAME] === Routes::CONTROLLER_SERVICE_NAME . ':getCollection') {
    

    Now that I see this here, I'm noticing that there is no mention about working copy collections in the documentation above.

    Can we add a mention to that?

  10. +++ b/src/Revisions/ResourceVersionRouteEnhancer.php
    @@ -0,0 +1,181 @@
    +      $latest_version_identifier = 'rel' . VersionNegotiator::SEPARATOR . VersionByRel::LATEST_VERSION;
    

    Would it make sense to have a VersionByRel::NAME constant to duck this magic number?

    Also, I see there is a strong coupling between this route enhancer and the version by rel negotiator. Would it make sense to move this block out of the route enhancer?


#262.4 let's set this apart, because I suspect this will be a friction point. Let's address the other stuff first and maybe jump into a 10 min video conference to sort this one out.

ndobromirov’s picture

264.1: There is this #3020112: [Regression] API change in jsonapi module. to fix the regression on josonapi_extras side if we keep the current direction. Already discussed from #236 to #241.

wim leers’s picture

#264:

  1. Again, see #259. This was explicitly fixed by me in the JSON:API 1.x->2.x update of JSON:API Extras. Exactly for this reason. If a module extends another module's value objects, it should do so via setter injection, to avoid the need to continuously update to stay in sync … which would also prevent compatibility with multiple versions of that module. This is going to be even more true once JSON:API is moved into core.
  2. I am leaning towards detecting that an entity type has/lacks a revision access checker instead of manually hard-coding that feature.

    If there was a way to detect this, then Drupal core would also be using it. There simply is no mechanism, no convention, nothing for this right now. This is exactly why it's hardcoded today.

  1. I first wanted to agree with you here. But when I dug into the code, I noticed that \Drupal\Core\Entity\EntityRepository::getTranslationFromContext() is doing exactly the same. And then I realized why: both "a translation" and "a revision" are not separate value objects, they're just different states of one and the same EntityInterface object. So this is actually correct.
  1. Wow, really interesting question wrt pre-flight requests! 👏
  2. Why would we need to? A version is selected through the URL's query string. That's what static::CACHE_CONTEXT is varying by: a specific URL query arg.
e0ipso’s picture

If there was a way to detect this, then Drupal core would also be using it. There simply is no mechanism, no convention, nothing for this right now. This is exactly why it's hardcoded today.

Exactly. That's where I'm going to. However, hard-coding this is to say that only core can provide entity types. We need a way for custom entity types to add their revision access checker.

ndobromirov’s picture

@eoipso - maybe another UI in jsonapi extra that will default to the 2 hard-coded ones and allow for users to change it explicitly?

gabesullice’s picture

StatusFileSize
new109.31 KB

Reroll.

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new109.08 KB
new5.14 KB

#264:

I'm sorry I can only do in-depth reviews in chunks. I can imagine how frustrating that may be. I apologize for delaying this.

No apologies necessary! That's understandable. For the moment, this isn't really delayed because we can't commit this till 2.0 is released.

1. See #265
2. Yes, let's talk about it :)
3. No, the argument passed to ensureVersionExists is the result of $storage->loadRevision($revision_id) which returns NULL if the revision ID doesn't exist.
4. Good catch!
5. See #266.5. This is also an important mechanism to ensure cacheability of 403 responses are per-revision. We need to add it even if a query parameter is not provided or else if you fetch /path then fetch /path?resource_version=id:{n-1}, you'll get the cached response for /path.
6. Almost the same as #5.
7. Good call.
8. Nope. If a query param is found to be invalid, all requests with that invalid param should get the cached exception, regardless of the path.
9. I don't think I really understand what you're asking for here. I updates the class comment, but I don't think that's what you meant.
10. I think that this is in the right place to have this logic because it's parameterizing the behavior of getCollection via the static::WORKING_COPIES_REQUESTED param and it keeps this error messages pretty centralized. As for the coupling, I think it's pretty superficial. The negotiator itself doesn't have anything to do with collections. It will never even be loaded. The reason VersionByRel::LATEST_VERSION is there is just to get the string "latest-version" instead of hardcoding it. That said, I think to enforce that it isn't coupled the negotiator, it would be better to actually hardcode the link relation names (they're not ever going to change).

Status: Needs review » Needs work

The last submitted patch, 270: 2992833-270.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new749 bytes
new109.12 KB

Whoops, looks like I missed this in the reroll.

Status: Needs review » Needs work

The last submitted patch, 272: 2992833-272.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new698 bytes
new109.12 KB

s/isMethodSafe/isMethodCacheable: isMethodSafe() is apparently deprecated.

gabesullice’s picture

Before this lands, I'd like to change `resource_version` to `resourceVersion` (snake case vs camel case) to align with v1.1 of the spec. See https://github.com/json-api/json-api/pull/1333#discussion_r243379557 for context.

Leaving as Needs Review for @e0ipso.

gabesullice’s picture

StatusFileSize
new679 bytes
new108.58 KB

@e0ipso, @Wim Leers and I discussed support for contrib and custom entity types in the API-First weekly meeting.

We agreed that JSON:API should not have an API to support them. However, that if it is an absolute necessity and cannot wait for official core support, there should be a way. As we typically do, we've decided that the best way to do this is allow JSON:API Extras an internal API it can override to achieve something less-than-ideal but of practical value to real-world applications.

As such, I've removed final from the EntityAccessChecker service and I've returned AccessResult::neutral() instead of AccessResult::forbidden() from EntityAccessChecker::checkRevisionViewAccess().

Without JSON:API Extras, the behavior will be the same. Access will not be allowed to entity types other than node and media. But if JSON:API Extras is installed, it can decorate the EntityAccessChecker class and override the checkRevisionViewAccess method, like so:

protected function checkRevisionViewAccess(EntityInterface $entity, AccountInterface $account) {
  return parent::checkRevisionViewAccess($entity, $account)->orIf(// JSON:API Extras access logic);
}

This will mean that JSON:API can still return AccessResult::forbidden() or AccessResult::allowed() to override JSON:API Extras, but Extras will be able to fill in gaps where JSON:API can't have an opinion (because there's not an official core API).


#275 next. Leaving @e0ipso assigned to get his +1 (to also make sure I've summarized things accurately).

gabesullice’s picture

StatusFileSize
new1.03 KB

I made the changes mentioned in #276 and also merged the 2.x branch into the patch. I accidentally posted the interdiff for the merge. This is the right interdiff.

wim leers’s picture

I think we could've achieved the same without removing final and requiring JSON:API Extras to decorate the jsonapi.entity_access_checker service … except then we'd need to make \Drupal\jsonapi\Access\EntityAccessChecker::checkRevisionViewAccess() public.

But … that's really a detail. It shouldn't hold back this patch. I'm only saying it to offer it as an alternative to both @gabesullice and @e0ipso.


Just like in #257, I think this is ready. I'm excited about the next steps: #3009588: Provide a collection resource where a version history can be obtained (`version-history`, `predecessor-version` and `successor-version` link relations) and #3020136: Author and register a JSON:API profile for resource versioning, and everything else in #2795279: [PP-2] [META] Revisions support.

e0ipso’s picture

I think this is ready as well. I'm very excited about this one! 🎉

e0ipso’s picture

Title: Add a revision id negotiation plugin to support entity revisions » Add a version negotiation to revisionable resource types
wim leers’s picture

Assigned: e0ipso » Unassigned
Status: Needs review » Reviewed & tested by the community

Yay! 🎉 So this will then land together with #2958554: Allow creation of file entities from binary data via JSON API requests in the 2.1 release of JSON:API! 🤘

Next step: #2992836: Provide links to resource versions (entity revisions), which was blocked on this. And after that, #3009588: Provide a collection resource where a version history can be obtained (`version-history`, `predecessor-version` and `successor-version` link relations), which is blocked on #2992836. (All part of #2795279: [PP-2] [META] Revisions support).

P.S.: thanks for improving the title, I've been restraining myself for 280 comments to refine it 😅 OCD trigger removed ✅

gabesullice’s picture

Status: Reviewed & tested by the community » Needs review
StatusFileSize
new4.8 KB
new110.62 KB

Since the release of SA-CONTRIB-2018-081, we (the maintainers) have since realized that we may need to be concerned about revision access when filtering on non-default revisions (good catch, Wim!).

Given the tremendous depth of that issue and this one, it was proposed that we simply remove resource versioning from collections and add that feature in a followup.

However, rather than go through all the effort of extracting that feature, I propose that we throw an exception when the filter query parameter is detected in conjunction with a non-default revision collection request. That lets us keep this patch as close to its RTBC form as possible. That means we can still provide revisioning on unfiltered collections, which will might meet the needs of most users doing content preview on collections (e.g. decoupled "most recent articles" components).

Note: this patch is also re-rolled against the latest 2.x branch.

Status: Needs review » Needs work

The last submitted patch, 282: 2992833-282.patch, failed testing. View results

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new1.89 KB
new110.66 KB
wim leers’s picture

Status: Needs review » Reviewed & tested by the community

WFM!

wim leers’s picture

StatusFileSize
new4.39 KB
new115.75 KB

Before committing, I wanted to run this to pass tests against not just 8.6, but also 8.5 and 8.7. It failed on 8.5 because \Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait only exists in >=8.6, it was introduced in #2952307: Move workflow config into standard profile.

So, rerolling this patch with that trait added as another backward compatibility thing (similar to \Drupal\jsonapi\BackwardCompatibility\tests\Traits\EntityReferenceTestTrait).

Actually … we already did this in #256. #262 apparently accidentally dropped that, and from that point forward we only tested with Drupal 8.6 & 8.7… So, bringing back #256's interdiff.

  • Wim Leers committed e409004 on 8.x-2.x authored by gabesullice
    Issue #2992833 by gabesullice, e0ipso, Wim Leers, garphy, justageek,...
wim leers’s picture

Status: Reviewed & tested by the community » Fixed

Green on all 3 core minors. ✅

We had explicit "ayes" from @gabesullice and @e0ipso to commit this late last night, so … let's do this! 💪

barbun’s picture

StatusFileSize
new22.42 KB

I had to re-apply #52 against the latest 1.x version and thought I'd post the updated patch. Obviously, be mindful that there might be things that would not work anymore and its an interim fix before assessing and upgrading to 2.x

wim leers’s picture

Hah … my research comment in #61 that covered CMIS and led me to less-than-favorable conclusions apparently was itself skeptically welcomed: https://roy.gbiv.com/untangled/2008/no-rest-in-cmis.

wim leers’s picture

  • Wim Leers committed ea06332 on 8.x-2.x
    Issue #3027501 by Wim Leers: [regression] Follow-up for #2995960 and #...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

rpayanm’s picture

Project: JSON:API » Drupal core
Version: 8.x-2.x-dev » 8.9.x-dev
Component: Code » jsonapi.module

Moving to Drupal core's issue queue.

I'm working on https://www.drupal.org/project/drupal/issues/3122113