Problem/Motivation

The existing test coverage and the existing code is unable to deal with entity types that have no canonical link template:

  1. When POSTing content entities without a canonical link template, such as the paragraph entity type (https://www.drupal.org/project/paragraphs), you get a 500 response due to an unhandled exception. Because the EntityResource::post() method assumes that the created entity has a canonical URL and tries to return it.

    Conclusion: EntityResource::post() is wrong.

    (First reported in #2836121: PHP error when POSTing content entities without a "canonical" link template.)

  2. We have the following assertions in \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPost:
        // DX: 404 when resource not provisioned, but HTML if canonical route.
        $response = $this->request('POST', $url, $request_options);
        if ($has_canonical_url) {
          $this->assertSame(404, $response->getStatusCode());
          $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
        }
        else {
          $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
        }
    

    This fails for entity types with no canonical routes because the Content-Type is still text/html; charset=UTF-8.

    Conclusion: the "else" case was never actually exercised.

In other words: the bug in both the controller logic and in the test coverage exists because nothing was exercising either one! Hence they must be solved in tandem.

Proposed resolution

  1. Update the EntityResource::post() code to first check if an entity type actually has a canonical link template, and only then return the canonical URL.
  2. Add EntityTestLabel test coverage, because it has no canonical link template, so that we are then testing the "else" case.

Remaining tasks

None.

User interface changes

None.

API changes

None.

Data model changes

None.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

arshadcn created an issue. See original summary.

Wim Leers’s picture

Status: Active » Postponed (maintainer needs more info)

This makes it difficult to test for cases where the Content-Type header is different from application/json.

That's the point.

The point is that \Drupal\Tests\rest\Functional\ResourceTestBase::assertResourceResponse() and \Drupal\Tests\rest\Functional\ResourceTestBase::assertResourceErrorResponse() are the methods you should use 95% of the time. They make things simple, short, clean, nice.

Whenever you need to deviate from that, suffer the consequences. Usually this is because of an unfortunate edge case or a bug! So let it be long, ugly, in-your-face.

shadcn’s picture

Hmm, I'll need to try and test another entity with no canonical route to confirm.

There's also the following in \Drupal\Tests\rest\Functional\ResourceTestBase::assertResourceResponse :)

if ($expected_status_code < 400) {
  $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
}
else {
  $this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
}
Wim Leers’s picture

Oh hahah, that's because #2805281: ?_format=hal_json error responses are application/json, yet should be application/hal+json should have simplified that but forgot to do so! That definitely should be improved, of course :)

shadcn’s picture

Status: Postponed (maintainer needs more info) » Active

@Wim Leers, so a follow up question, wouldn't entities with no canonical url return text/html; charset=UTF-8 as well? Shouldn't they?

    // DX: 404 when resource not provisioned, but HTML if canonical route.
    $response = $this->request('POST', $url, $request_options);
    if ($has_canonical_url) {
      $this->assertSame(404, $response->getStatusCode());
      $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
    }
    else {
      $this->assertResourceErrorResponse(404, 'No route found for "GET ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
    }
Wim Leers’s picture

No, because:

  1. A \Symfony\Component\HttpKernel\Exception\NotFoundHttpException exception is thrown by the routing system.
  2. The format is json (or hal_json, or …), which is one of the serialization formats.
  3. Hence the NotFoundHttpException (404) is caught by \Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber, which converts it to a JSON/HAL+JSON/… response.
shadcn’s picture

@Wim Leers Thanks. A follow up question. Why do test for text/html; charset=UTF-8 for entities with canonical url but not for non-canonical?

Wim Leers’s picture

#7: because $this->assertResourceErrorResponse() takes care of asserting the Content-Type response header for us :) That's the thing: anything that behaves in a strange/unexpected way is going to have additional/ugly looking assertions. The fact that we get a HTML response is strange. So the assertions stand out.

shadcn’s picture

Title: Add a $expected_headers parameter to \Drupal\Tests\rest\Functional\ResourceTestBase::assertResourceResponse » Assert text/html response for entities with non-canonical url in EntityResourceTestBase
Assigned: Unassigned » shadcn
Status: Active » Needs work

Updating the title to reflect what we need to change here. See also https://www.drupal.org/node/2843752#comment-11963791.

shadcn’s picture

Issue summary: View changes
shadcn’s picture

Status: Needs work » Needs review
FileSize
7.9 KB

OK. Let's try this.

shadcn’s picture

Didn't mean to comment out the uuid tests :)

Wim Leers’s picture

  1. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -578,7 +578,6 @@ public function testPost() {
    -    $has_canonical_url = $this->entity->hasLinkTemplate('canonical');
    

    Hah, interesting! Apparently testPost() didn't even need $has_canonical_url at all then!

  2. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -597,15 +596,10 @@ public function testPost() {
    -    // DX: 404 when resource not provisioned, but HTML if canonical route.
    +    // DX: 404 when resource not provisioned. HTML because no ?_format.
    
    @@ -621,16 +615,11 @@ public function testPost() {
    -    // DX: 415 when no Content-Type request header, plain text if canonical URL.
    +    // DX: 415 when no Content-Type request header. HTML because no ?_format.
    

    These aren't consistent with the comments in testGet(). Fixed.

  3. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -783,7 +772,7 @@ public function testPatch() {
    -    // DX: 404 when resource not provisioned, but 405 if canonical route.
    +    // DX: 405 if canonical route, 404 for non canonical. HTML because no ?_format.
    

    Same remark as above, and exceeds 80 cols. Fixed.

  4. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -791,17 +780,23 @@ public function testPatch() {
    -    // DX: 405 when resource not provisioned.
    +    // DX: 405 when resource not provisioned. 404 for non canonical.
    

    This comment is no longer accurate. Fixed.

  5. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -791,17 +780,23 @@ public function testPatch() {
    -    $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
    -    $this->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
    +    if ($has_canonical_url) {
    +      $this->assertSame(405, $response->getStatusCode());
    +      $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
    +    }
    +    else {
    +      $this->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
    +    }
    

    The "if" case" now is missing an assertion for the content type.

    And AFAICT, this can use the original assertion!

    Fixed.

  6. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -809,16 +804,11 @@ public function testPatch() {
    -    // DX: 415 when no Content-Type request header, but HTML if canonical route.
    +    // DX: 415 when no Content-Type request header.
         $response = $this->request('PATCH', $url, $request_options);
    -    if ($has_canonical_url) {
    -      $this->assertSame(415, $response->getStatusCode());
    -      $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
    -      $this->assertTrue(FALSE !== strpos((string) $response->getBody(), htmlspecialchars('No "Content-Type" request header specified')));
    -    }
    -    else {
    -      $this->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
    -    }
    +    $this->assertSame(415, $response->getStatusCode());
    +    $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type'));
    +    $this->assertTrue(FALSE !== strpos((string) $response->getBody(), htmlspecialchars('No "Content-Type" request header specified')));
    

    Interesting, so we never actually hit the "else" case here. Great catch.

    However, I suspect this is because we have no test coverage for entity types without a canonical link template.

    I think that before we make this change, we should add test coverage for one of these, e.g. \Drupal\entity_test\Entity\EntityTestLabel, so that we can be certain that we really don't need this.

    (We do e.g. have ConfigTest, which also doesn't have a canonical link template, but that one only supports GET, because we don't yet support modifications to config entities.)

  7. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -977,7 +967,7 @@ public function testDelete() {
    -    // DX: 404 when resource not provisioned, but 405 if canonical route.
    +    // DX: 405 when resource not provisioned. 404 for non canonical.
    

    I think this was clearer before. Although the bit about "plain text or HTML" should be appended. Fixed.

  8. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -985,18 +975,22 @@ public function testDelete() {
    -
    

    Unnecessary change, reverting.

  9. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -985,18 +975,22 @@ public function testDelete() {
    -    // DX: 405 when resource not provisioned.
    +    // DX: 405 when resource not provisioned. 404 for non canonical.
    

    Correct change, but made consistent with comments in testGet() again. Fixed.

  10. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -985,18 +975,22 @@ public function testDelete() {
    -    $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
    -    $this->assertResourceErrorResponse(405, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
    -
    +    if ($has_canonical_url) {
    +      $this->assertSame(405, $response->getStatusCode());
    +      $this->assertSame(['GET, POST, HEAD'], $response->getHeader('Allow'));
    +    }
    +    else {
    +      $this->assertResourceErrorResponse(404, 'No route found for "DELETE ' . str_replace($this->baseUrl, '', $this->getUrl()->setAbsolute()->toString()) . '"', $response);
    +    }
    

    The "if" case" now is missing an assertion for the content type.

    And AFAICT, this can use the original assertion!

    Fixed.


I fixed all my own nits. You only need to address point 6.

Anonymous’s picture

#13 amazing review!

I wanted to make a new issue for point 6, but it is heavily dependent on the current patch. Therefore, I attached here. Patch entity_test_label-only.patch is interdiff between 13 and 14 patches.

Also patch has

  public function testPost() {
    $this->markTestSkipped();
  }

because testPost() causes a failure here:

// 201 for well-formed request.
    $response = $this->request('POST', $url, $request_options);
    $this->assertResourceResponse(201, FALSE, $response);
# status 500
Drupal\Core\Entity\Exception\UndefinedLinkTemplateException: No link template 'canonical' found for the 'entity_test_label' entity type in Drupal\Core\Entity\Entity->toUrl() (line 214 of core\lib\Drupal\Core\Entity\Entity.php).

I'm absolutely not sure in my skill, but if test is correct, hence PATCH work normal with point 6 change.

The last submitted patch, 14: entity_test_label-only.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 14: 2853211-14.patch, failed testing.

Anonymous’s picture

Status: Needs work » Needs review
FileSize
19.17 KB
690 bytes

oops, sorry!

Anonymous’s picture

sorry again :(

The last submitted patch, 17: 2853211-16.patch, failed testing.

The last submitted patch, 18: entity_test_label-only-2853211-17.patch, failed testing.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
4.28 KB
21.53 KB

#14: heh glad you liked my review :)

The failures of the test-only patch in #18 prove that indeed the existing assertions are wrong. And like I said in #13.6: we never actually hit the "else" case with the existing tests. This new test does and fails. Perfect.

Thanks to this additional test coverage, this guarantees that the changes made by this patch are actually correct.

#18 looks perfect. And #18 is identical to #13, the only changes are test coverage expansions. Unfortunately, perfect with one exception:

+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTestLabel/EntityTestLabelResourceTestBase.php
@@ -0,0 +1,163 @@
+  public function testPost() {
+    $this->markTestSkipped();
+  }

This we can't have.

You described in #14 why this is failing. In fact, you have the answer right there in the exception that you quoted! :) The root cause is that EntityTestLabel has no canonical link template (which is why we're testing this entity type in the first place, see #13.6), yet EntityResource::post() and EntityResourceTestBase::testPost() ignore this, and assume that it's possible to always send a Location header. But that's not the case. Also note that https://tools.ietf.org/html/rfc2616#section-10.2.2 says "SHOULD", not "MUST" (about providing a location in the response).

Wim Leers’s picture

(I RTBC'ed the work by @arshadcn + @vaplas, and made only trivial changes in #21 to address the last small remark. I think that's acceptable, given that I'm a REST module maintainer.)

alexpott’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php
@@ -189,8 +189,12 @@ public function post(EntityInterface $entity = NULL) {
-      $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE);
-      return new ModifiedResourceResponse($entity, 201, ['Location' => $url->getGeneratedUrl()]);
+      $headers = [];
+      if (in_array('canonical', $entity->uriRelationships(), TRUE)) {
+        $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE);
+        $headers['Location'] = $url->getGeneratedUrl();
+      }
+      return new ModifiedResourceResponse($entity, 201, $headers);

Doesn't this mean we're fixing a bug here? The only run-time change in this patch was introduced in #21 - the comment that rtbc'd the patch. Setting back to needs review just to make sure that this bit gets further review. It looks sensible to me - but perhaps the issue title or summary needs an update to detail what is being fixed. At the moment it sounds all about tests.

Wim Leers’s picture

Yes, the title/IS need to be updated. This is fixing a bug in test coverage, which led us to discover a small bug in EntityResource, i.e. outside test coverage.

Wim Leers’s picture

Eh… turns out that the bug that I fixed in #21 was reported at #2836121: PHP error when POSTing content entities without a "canonical" link template.

I'll migrate the necessary information onto this one. This issue includes the test coverage already.

Wim Leers’s picture

Title: Assert text/html response for entities with non-canonical url in EntityResourceTestBase » EntityResource::post() incorrectly assumes that every entity type has a canonical URL
Issue summary: View changes
Related issues:

IS completely rewritten. Title updated.

Wim Leers’s picture

This blocks the https://www.drupal.org/project/paragraphs contrib module because it's impossible to POST paragraph content entities: they result in an unhandled exception and hence a 500 response.

Wim Leers’s picture

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: +SprintWeekend2017

Back to RTBC.

Wim Leers’s picture

Closed #2836121: PHP error when POSTing content entities without a "canonical" link template as a duplicate. Migrated its tags (accidentally did that in #29).

cosmicdreams’s picture

I believe I'm running into this issue when using the Entity Pilot git module. My specific issue is that whenever it uses TypeLinkManager::getTypeInternalIds is called the $type_uri that is supplied is like /rest/type/taxonomy_term/category whereas the type_uri that is retrieved from TypeLinkManager::getTypes looks like http://:/rest/type/taxonomy_term/product_category

So I've been investigating whether the types are being stored incorrectly or retrieved incorrectly. I guess i don't know what my expectations should be because to me the json looks right.

"_links": {
        "self": {
            "href": "\/taxonomy\/term\/6?_format=hal_json"
        },
        "type": {
            "href": "http:\/\/:\/rest\/type\/taxonomy_term\/category"
        }
    },

but somehow when json_decode is finished with decoding that json it converts to /rest/type/taxonomy_term/category

Which leads me to this issue and how it appears that this canonical url needs to be fixed. Is there any chance we can get this patch working with Drupal 8.2 / 8.3 ?

Wim Leers’s picture

@cosmicdreams: what you are describing is all specific to the HAL module. AFAICT it's completely unrelated to this issue. Please open a new issue against the hal.module component.

cosmicdreams’s picture

Thanks Wim. Will do.

Wim Leers’s picture

Thank you! Will respond to that issue as soon as you open it. Please make sure to provide steps to reproduce, then I'm certain we can get it fixed very quickly.

cosmicdreams’s picture

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 21: 2853211-21.patch, failed testing.

Anonymous’s picture

Status: Needs work » Reviewed & tested by the community
shadcn’s picture

Yeah let's get this in. I have some time this week to work on more REST entity tests :)

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 21: 2853211-21.patch, failed testing.

Anonymous’s picture

Status: Needs work » Reviewed & tested by the community

Last fail is random fail. Retest is green. Separate issue #2861067: Random fail in Drupal\aggregator\Tests\FeedAdminDisplayTest::testFeedUpdateFields and back to RTBC.

alexpott’s picture

Version: 8.4.x-dev » 8.3.x-dev
Status: Reviewed & tested by the community » Patch (to be ported)

Committed 0edd07b and pushed to 8.4.x. Thanks!

I think we should commit this to 8.3.x - going to ask other committers.

Anonymous’s picture

@alexpott, thank you very match! We really need this for 8.3.x, otherwise a whole series of tests (issues) just stall.

Wim Leers’s picture

+++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php
@@ -189,8 +189,12 @@ public function post(EntityInterface $entity = NULL) {
-      $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE);
-      return new ModifiedResourceResponse($entity, 201, ['Location' => $url->getGeneratedUrl()]);
+      $headers = [];
+      if (in_array('canonical', $entity->uriRelationships(), TRUE)) {
+        $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE);
+        $headers['Location'] = $url->getGeneratedUrl();
+      }
+      return new ModifiedResourceResponse($entity, 201, $headers);

This is a pure bugfix.

Contrib modules having their own entity types without a canonical URL would also run into this bug. In fact, that's already the case, see #2836121: PHP error when POSTing content entities without a "canonical" link template, which reported it for the paragraph entity type!

Therefore I strongly agree this should be committed to 8.3.x.

alexpott’s picture

Category: Task » Bug report

So let's make this a bugfix.

  • alexpott committed 0edd07b on 8.4.x
    Issue #2853211 by vaplas, Wim Leers, arshadcn: EntityResource::post()...
catch’s picture

Yep #44 is the only real change here, and agreed it's just a bugfix and that we should backport it.

Wim Leers’s picture

Yeah it was originally a task (improve test coverage), then in doing so we found an actual bug outside of test coverage. Sorry for not changing the category earlier.

  • alexpott committed 0c17026 on 8.3.x
    Issue #2853211 by vaplas, Wim Leers, arshadcn: EntityResource::post()...
alexpott’s picture

Status: Patch (to be ported) » Fixed

Fixed a couple of merge conflicts on commit in comments and what with what is in the 8.4.x patch. The conflicts where caused by what was in HEAD.

Wim Leers’s picture

Thanks!

Wim Leers’s picture

Can we please also assign issue credit to @ruloweb (for his contributions at #2836121-2: PHP error when POSTing content entities without a "canonical" link template, which was closed as a duplicate). Perhaps also @dagmar.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

groovedork’s picture

I ran into this bug. How will this patch end up in normal Drupal installations? When Drupal 8.4 arrives? Or with a new Paragraphs version?