Problem/Motivation

This core bug blocks this JSON API contrib module issue: #2831137: Remove the need for ?_format=api_json: assume the use of the 'api_json' format for routes managed by JSON API.

Observation 1

Currently, the fallback for \Drupal\Core\StackMiddleware\NegotiationMiddleware::getContentType() (i.e. if no ?_format= querystring is specified) is this:

    // Do HTML last so that it always wins.
    return 'html';

And \Drupal\Core\StackMiddleware\NegotiationMiddleware::handle() does this:

    // Determine the request format using the negotiator.
    if ($requested_format = $this->getContentType($request)) {
      $request->setRequestFormat($requested_format);
    }

The end result is that $requested_format is never empty. It always is set to 'html'.

Observation 2

Now let's look at \Symfony\Component\HttpFoundation\Request::getRequestFormat(), which is what the router (and controllers) use to determine the request format

    /**
     * Gets the request format.
     *
     * Here is the process to determine the format:
     *
     *  * format defined by the user (with setRequestFormat())
     *  * _format request parameter
     *  * $default
     *
     * @param string $default The default format
     *
     * @return string The request format
     */
    public function getRequestFormat($default = 'html')
    {
        if (null === $this->format) {
            $this->format = $this->get('_format', $default);
        }

        return $this->format;
    }

Result

The result is that Request::getRequestFormat()'s $default parameter is broken in Drupal, because NegotiationMiddleware always sets it to 'html'. Even when there is no _format request parameter.

In other words: NegotiationMiddleware breaks the Symfony Request API.

Proposed resolution

Only let NegotiationMiddleware call Request::setFormat() when there actually is a _format request parameter.

Remaining tasks

TBD

User interface changes

None.

API changes

None.

Data model changes

None.

CommentFileSizeAuthor
#88 2854543-88.patch15.82 KBWim Leers
#88 interdiff.txt1.32 KBWim Leers
#85 2854543-85.patch15.64 KBWim Leers
#85 interdiff.txt1.97 KBWim Leers
#72 2854543-72-PASS--51_plus_test_plus_67.patch15.55 KBWim Leers
#72 2854543-72-FAIL--51_plus_test.patch13.75 KBWim Leers
#72 interdiff-51-failing_test.txt1.73 KBWim Leers
#67 interdiff-51-67.txt2.22 KBpolynya
#67 interdiff-57-67.txt1.92 KBpolynya
#67 2854543-67.patch14.36 KBpolynya
#61 Screen Shot 2018-04-04 at 12.23.39.png63.36 KBWim Leers
#60 interdiff-57-58.txt4.26 KBWim Leers
#60 interdiff-51-58.txt2.57 KBWim Leers
#60 interdiff-51-57.txt2.1 KBWim Leers
#58 2854543-58.patch13.96 KBpolynya
#57 2854543-57.patch14.25 KBpolynya
#56 2854543-56.patch2.58 KBpolynya
#51 2854543-51.patch13.13 KBWim Leers
#51 interdiff.txt3.06 KBWim Leers
#48 2854543-48.patch12.72 KBWim Leers
#48 interdiff.txt3.68 KBWim Leers
#47 2854543-47.patch11.85 KBWim Leers
#47 interdiff.txt767 bytesWim Leers
#45 2854543-45.patch11.87 KBWim Leers
#45 interdiff.txt761 bytesWim Leers
#44 2854543-44.patch11.9 KBWim Leers
#44 interdiff.txt993 bytesWim Leers
#42 2854543-42.patch11.87 KBWim Leers
#42 interdiff.txt1.01 KBWim Leers
#39 2854543-39.patch11.95 KBWim Leers
#39 interdiff.txt2.66 KBWim Leers
#38 2854543-38.patch11.14 KBWim Leers
#38 interdiff.txt5.84 KBWim Leers
#36 2854543-36.patch10.83 KBWim Leers
#36 interdiff.txt2.75 KBWim Leers
#31 2854543-31.patch9.63 KBWim Leers
#31 interdiff.txt2.87 KBWim Leers
#30 2854543-30.patch7.64 KBWim Leers
#30 interdiff.txt1.16 KBWim Leers
#28 interdiff-16-28.txt2.24 KBmpdonadio
#28 2854543-28.patch6.12 KBmpdonadio
#16 interdiff-2854543.txt1.58 KBdawehner
#16 2854543-16.patch8.29 KBdawehner
#13 2854543-13.patch6.54 KBWim Leers
#13 interdiff.txt3.46 KBWim Leers
#7 2854543-7.patch3.15 KBWim Leers
#7 interdiff.txt1.25 KBWim Leers
#2 2854543-2.patch3.06 KBWim Leers
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Wim Leers created an issue. See original summary.

Wim Leers’s picture

Status: Active » Needs review
FileSize
3.06 KB
Wim Leers’s picture

Issue tags: +symfony

I marked this a major bug, because this breaks one of the foundations of Symfony that we're building upon.

Wim Leers’s picture

Title: NegotiationMiddleware always calls $request->setRequestFormat('html') by default, but shouldn't » NegotiationMiddleware calls $request->setRequestFormat('html') when there is no _format request parameter, but shouldn't

Clarifying title.

Wim Leers’s picture

Issue tags: +API-First Initiative
dawehner’s picture

  1. +++ b/core/tests/Drupal/Tests/Core/StackMiddleware/NegotiationMiddlewareTest.php
    @@ -71,7 +71,7 @@ public function testFormatViaQueryParameter() {
       public function testUnknowContentTypeReturnsHtmlByDefault() {
    

    Should we rename the method now?

  2. +++ b/core/tests/Drupal/Tests/Core/StackMiddleware/NegotiationMiddlewareTest.php
    @@ -71,7 +71,7 @@ public function testFormatViaQueryParameter() {
    -    $this->assertSame('html', $this->contentNegotiation->getContentType($request));
    +    $this->assertSame(NULL, $this->contentNegotiation->getContentType($request));
    
    @@ -83,7 +83,7 @@ public function testUnknowContentTypeButAjaxRequest() {
    -    $this->assertSame('html', $this->contentNegotiation->getContentType($request));
    +    $this->assertSame(NULL, $this->contentNegotiation->getContentType($request));
    

    What about using assertNull though.

Wim Leers’s picture

FileSize
1.25 KB
3.15 KB

Done.

Also, the best proof that this doesn't break anything is the fact that this passed all of core's tests. That means 100% of requests worked fine with this change. I think that's pretty convincing.

dawehner’s picture

Status: Needs review » Reviewed & tested by the community

Thank you.

neclimdul’s picture

I'm not sure its true that the test suite passes since we changed it. I'm pretty sure from what I remember of that original issue that was explicitly the desired behavior. We didn't want anything to fall back on Content-type because we didn't trust reverse proxies to correctly handle the Varies header.

alexpott’s picture

Status: Reviewed & tested by the community » Needs review

Shouldn't we add a test with code similar to what the contrib module is trying to do. So that we can be sure it works? At the moment the only tests changed here are unit tests which seem pretty tied to our assumptions about the way the code works.

Also I think @neclimdul's comment needs addressing. However it's going to be hard to reason about because this behaviour from the routing merge...

git log -S "// Do HTML last so that it always wins."
commit f29f1e212967ce4aa5f879a30d828565bb44089c
Author: Alex Pott <alex.a.pott@googlemail.com>
Date:   Thu Aug 27 00:27:14 2015 +0100

    Issue #2506533 by Jaesin, minnur, neclimdul, sdstyles, dawehner, Crell: Remove ContentNegotiation and embed functionality in the middleware

commit 223049a02c9e8b07e3755f05c6bcdab2094acf39
Author: Katherine Bailey <katherine@katbailey.net>
Date:   Wed Apr 18 22:06:17 2012 -0700

    Use the getFormat() method on the request object for content type negotiation, and fallback to html in the case of no Accept header.
Wim Leers’s picture

Assigned: Unassigned » Wim Leers

Yeah, commit 223049a02c9e8b07e3755f05c6bcdab2094acf39 is A) tiny, B) not associated with an issue so probably was a git merge, C) has zero test coverage.

I'm pretty sure @neclimdul is wrong about this one. The thing is that the test coverage that I'm modifying is the content negotiation test coverage. But when you use Symfony, a big part of the content negotiation irrevocably lies in $request->getRequestFormat():

    /**
     * Gets the request format.
     *
     * Here is the process to determine the format:
     *
     *  * format defined by the user (with setRequestFormat())
     *  * _format request parameter
     *  * $default
     *
     * @param string $default The default format
     *
     * @return string The request format
     */
    public function getRequestFormat($default = 'html')
    {
        if (null === $this->format) {
            $this->format = $this->get('_format', $default);
        }

        return $this->format;
    }

i.e. its handling of defaults is absolutely essential. Which is why there's no need for the content negotiation system to explicitly set HTML, because that's already the default. Which is why #7 passes tests.


We didn't want anything to fall back on Content-type because we didn't trust reverse proxies to correctly handle the Varies header.

With this change, we're not at all relying on anything in content negotiation, nor anything Content-Type- or Vary-related. We're relying on Request::getRequestFormat().


Is this a flawed, confusing, hard-to-debug design? Probably. But we adopted the Symfony HttpFoundation component, with its pros and cons, its strengths and its flaws. The current code is breaking Request::getRequestFormat(). And that's the problem this is fixing.


Working on the test coverage.

dawehner’s picture

Shouldn't we add a test with code similar to what the contrib module is trying to do.

Don't we have that already? \Drupal\KernelTests\Core\Routing\ContentNegotiationRoutingTest::testFullNegotiation does that already

Wim Leers’s picture

FileSize
3.46 KB
6.54 KB

I worked on test coverage, as I promised. But it wasn't the 15-20 minute task I thought it was. I spent hours debugging.

Turns out that because \Drupal\page_cache\StackMiddleware\PageCache::getCacheId() also calls $request->getRequestFormat(), you always get 'html' back, unless you're authenticated and hence that code is not executed.

And because of the logic in that method, whatever getRequestFormat() returns the first time, it will also return in the future. This makes sense, because otherwise it'd be hellishly unpredictable.

So… we could say the flaw is that PageCache uses Request::getRequestFormat(), but that is the Symfony API. I'm afraid that the only possible conclusion is that the Symfony design is completely flawed here. It simply cannot reliably support the "default format for a given route" concept. It's possible that they consider this an edge case. But this is a foundational component. Such a component should support many use cases.

And regardless of whether the fault lies with Symfony's API design or Drupal's use/integration of/with it, there is one thing here that's undeniable: I was convinced this is correct. @dawehner RTBC'd this and in #12 even argues that we have sufficient test coverage.

If both @dawehner and I are both unable to reason about the abstractions, the layers, and what will work and what will not, then we've got a clearly flawed system. :( And we're both far more experienced than an average Drupal developer in these areas.

So, I am forced to give up on this issue. And frankly, on fixing the routing system. The best we can do with this routing system, is fix as many symptoms as possible. But there's no way to fix the root cause of this. It's a flaw inherent in its design.

Status: Needs review » Needs work

The last submitted patch, 13: 2854543-13.patch, failed testing.

Wim Leers’s picture

+++ b/core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php
@@ -0,0 +1,27 @@
+    $this->assertSame('format:html', $this->getSession()->getPage()->getContent());

Eh, this should have been 'format:json'.

But you can see in the failed test the error that you get instead. So the test is slightly wrong, but it still proves the point I made in #13.

dawehner’s picture

Here is a way to solve this issue. We override the request object and let it behave like we want it to behave, aka. not storing the default value, but still have the same fallback. I actually believe we could fix that upstream, but here is a patch to prove the point.

dawehner’s picture

Status: Needs work » Needs review

Let's see what the testbot says

Status: Needs review » Needs work

The last submitted patch, 16: 2854543-16.patch, failed testing.

klausi’s picture

  1. +++ b/core/lib/Drupal/Component/HttpFoundation/Request.php
    @@ -0,0 +1,20 @@
    +      $this->format = $this->get('_format');
    

    Never directly use ->get() on the request object, since that method behaves differently depending on Symfony major version. Use ->query->get() instead.

  2. +++ b/index.php
    @@ -15,7 +15,9 @@
    +Request::setFactory('\Drupal\Core\DrupalKernel::createRequest');
     $request = Request::createFromGlobals();
    +
    

    Interesting. So everywhere Request::createFromGlobals() is called we need to make sure the factory is set.

    It looks like we can't put this in a more central place like DrupalKernel. We could put it into the constructor, not sure if that is a good idea.

    Or maybe we should introduce a \Drupal::createRequestFromGlobals() method that makes sure the factory is set. Then we could replace all our Request::createFromGlobals() calls.

dawehner’s picture

Never directly use ->get() on the request object, since that method behaves differently depending on Symfony major version. Use ->query->get() instead.

LOL, well, that is a copy from upstream code.

Note: Here is the corresponding PR: https://github.com/symfony/symfony/pull/21805

It looks like we can't put this in a more central place like DrupalKernel. We could put it into the constructor, not sure if that is a good idea.

yeah i thought about that for a second, and wasn't sure this would be a good idea at all.

Or maybe we should introduce a \Drupal::createRequestFromGlobals() method that makes sure the factory is set. Then we could replace all our Request::createFromGlobals() calls.

Good idea. Well, let's see whether we can just get the symfony PR in, as this solves the problem as well.

Wim Leers’s picture

The upstream PR landed. See https://github.com/symfony/symfony/pull/21805. It's in Symfony 2.7.25 and 2.8.18. Drupal core is currently on 2.8.16.

Wim Leers’s picture

#16: are you sure this is correct? I think the PR just results in PageCache defaulting to html, and then our controller defaulting to json. But that means PageCache will have cached a JSON response with a HTML cache ID. And it means that \Drupal\Core\Cache\Context\RequestFormatCacheContext will lie/be broken in a similar way.

So unless I'm mistaken, the PR that was merged actually causes more problems than it solves. :(

EDIT: To be clear: I'd love to be wrong here.

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.6 was released on August 2, 2017 and is the final full bugfix release for the Drupal 8.3.x series. Drupal 8.3.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.4.0 on October 4, 2017. (Drupal 8.4.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.4.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.5.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Wim Leers’s picture

Wim Leers’s picture

Version: 8.4.x-dev » 8.5.x-dev

Drupal 8.4.4 was released on January 3, 2018 and is the final full bugfix release for the Drupal 8.4.x series. Drupal 8.4.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.5.0 on March 7, 2018. (Drupal 8.5.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.5.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.6.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

mpdonadio’s picture

Status: Needs work » Needs review
FileSize
6.12 KB
2.24 KB

Ok, this should backout the changes now that https://github.com/symfony/symfony/pull/21805 is in.

DefaultFormatTest and NegotiationMiddlewareTest pass locally. Let's do a full run to see what else happens.

Status: Needs review » Needs work

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

Wim Leers’s picture

Looks like 100% of the failures are due to the weird "403 when it should be a 406" bug, for which we have #2805279: Routing system + authentication system + format-specific routes (e.g. those in rest.module) = frustrating, unhelpful 403 responses instead of 406 responses to fix that.

This patch is apparently making that problem worse … but also consistent. So in a way, this is actually better. I think we should also look into #2805279, and hopefully solve both at the same time here. But first, let's try to get this to green.

Wim Leers’s picture

This introduces new behavior in core: when a route supports a single format, that format will be picked automatically. So we need to expand our test coverage.

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

Wim Leers’s picture

A problem that still exists is that \Drupal\page_cache\StackMiddleware\PageCache::getCacheId() calls Request::getRequestFormat() to look up a potential cache hit. And that defaults to html. But I guess that's only a cosmetic problem: the cache ID in cache_page is not exactly the most important thing. What matters most, is that things behave as expected.

Wim Leers’s picture

Great, all failures in #28 were fixed by #30!

But in #30 we now face a new set of failures, later in the test coverage: all basic_auth tests are failing because what is expected to be a 403 is now a 401.

Status: Needs review » Needs work

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

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
2.75 KB
10.83 KB

#31 has new failures, because thanks to the extra test coverage it adds, Page Cache & Dynamic Page Cache are being warmed already. Easily remedied.

Status: Needs review » Needs work

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

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
5.84 KB
11.14 KB

#36 should have the same remaining failures as #30. Those are the basic_auth failures. Because when basic_auth is required and you try to access it anonymously, you don't get a 403 as the patches above are asserting, but a 401!

We could remedy this by moving the test coverage that #31 + #36 added to happen after we are authenticated. Then the 403s will be 403s always, and not 401s just when using basic_auth.

Wim Leers’s picture

Fix more fails.

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

Status: Needs review » Needs work

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

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
1.01 KB
11.87 KB

Status: Needs review » Needs work

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

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
993 bytes
11.9 KB

This should be green then!

Wim Leers’s picture

Fix CS violation.

gabesullice’s picture

Status: Needs review » Needs work

Nice! A few questions and a nit :)

  1. +++ b/core/lib/Drupal/Core/StackMiddleware/NegotiationMiddleware.php
    @@ -88,8 +90,8 @@ protected function getContentType(Request $request) {
    -    // Do HTML last so that it always wins.
    -    return 'html';
    +    // No format was specified in the request.
    +    return NULL;
    

    This is the significant change that will fix the root cause of this issue.

  2. +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
    @@ -156,12 +156,22 @@
    +  protected function provisionEntityResource($single_format = FALSE) {
    

    Small suggestion: why not $formats = NULL then $format = is_null($format) ? [static::$format, 'foobar'] : $formats;? Then for 'no format' just pass an empty list [].

  3. +++ b/core/modules/system/tests/modules/default_format_test/default_format_test.routing.yml
    @@ -0,0 +1,17 @@
    +    # Same controller + method!
    +    _controller: '\Drupal\default_format_test\DefaultFormatTestController::content'
    

    Good note. Am I missing how a single, non-html format is tested?

  4. +++ b/core/modules/system/tests/modules/default_format_test/default_format_test.routing.yml
    @@ -0,0 +1,17 @@
    \ No newline at end of file
    

    Nit: missing newline

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
767 bytes
11.85 KB
  1. Correct!
  2. Hm … so far provisionEntityResource() has meant "provision a REST resource for the currently tested entity type for the currently tested format". Requiring an explicit list of formats to be passed would change that.
  3. Either I'm misreading due to the late hour, or … you overlooked it:
    default_format_test.machine:
    …
      requirements:
        _access: 'TRUE'
        _format: 'json'
    
  4. Fixed.
Wim Leers’s picture

+++ b/core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php
@@ -0,0 +1,27 @@
+    $this->drupalGet('/default_format_test/human');
+    $this->assertSame('format:html', $this->getSession()->getPage()->getContent());
...
+    $this->drupalGet('/default_format_test/machine');
+    $this->assertSame('format:html', $this->getSession()->getPage()->getContent());

Although this of course is proving that this is not quite working yet. At least not for this edge case.

I guess that's what @gabesullice meant.

(This is an edge case because the controller itself is using $request->getRequestFormat(), which is not something that'd typically happen.)

Easy fix: in \Drupal\Core\Routing\RequestFormatRouteFilter, we must persist the format that we picked.

Status: Needs review » Needs work

The last submitted patch, 48: 2854543-48.patch, failed testing. View results

gabesullice’s picture

Full disclosure: that's not what I noticed. I did indeed overlook that the routes were different even though the controller was the same, so they indeed had only one format :)

+++ b/core/tests/Drupal/FunctionalTests/Routing/DefaultFormatTest.php
@@ -20,7 +20,7 @@ public function testFoo() {
-    $this->assertSame('format:html', $this->getSession()->getPage()->getContent());
+    $this->assertSame('format:json', $this->getSession()->getPage()->getContent());

Should we keep the incorrect format call and assert 406 here too?

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
3.06 KB
13.13 KB
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -470,17 +470,16 @@ public function testGet() {
-    $this->assertSame(403, $response->getStatusCode());
-    $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
+    $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', 'MISS');

This change caused problems for those cases where there is a canonical URL, i.e. where there is a HTML response at that same URL.

Wim Leers’s picture

Should we keep the incorrect format call and assert 406 here too?

There was no incorrect ?_format=… query string? And if we specify ?_format, then we're no longer testing the default format.

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community

There was no incorrect ?_format=… query string?

Never mind me, I was being dense.

This looks good to me now :)

codesmith’s picture

I applied the patch in #51. I updated my View -> Rest export display to have "json" be the only "Accepted request formats" in Format -> Serializer -> Settings. Now I no longer need to have the _format=json query argument.

Thanks!

pturner1989’s picture

I haven't worked out why yet but this patch isn't working for the Relaxed module which should return JSON based on CouchDB protocol.

/relaxed - Works
/relaxed/live - Works
/relaxed/live/* (/relaxed/live/_changes) - Client Error

All are configured the same with only json accepted so not sure why this is happening. Trying to work through it now.

polynya’s picture

I have been trying to get patch #51 working with Relaxed as well. One issue is that RequestFormatRouteFilter->getDefaultFormat() always returns 'html' if there are multiple routes in the collection.

I have created a new patch which checks if all the routes require the same format. This works for the RemoteCheck plugins in Relaxed - check for Relaxed Remote errors on the status report page. Deploy still fails because rest.relaxed.doc.GET accepts json and mixed formats.

polynya’s picture

Sorry, please try this patch not #56.

polynya’s picture

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

I have updated my patch (based on #51) so that RequestFormatRouteFilter->filter() keeps routes if they support only one format and the request does not specify the format.

Status: Needs review » Needs work

The last submitted patch, 58: 2854543-58.patch, failed testing. View results

Wim Leers’s picture

#54: thanks for testing!
#55: Do you have news?
#56 + #57 + #58: please post an interdiff in the future. I've created interdiffs for your patches now. Without those interdiffs, it's impossible to see what you changed.
Also, this patch was RTBC. You say it didn't work for you. Ideally, you'd have added a new test that fails, which would prove that it didn't work in some case. Then once we'd fix that bug, we'd also have test coverage preventing this from ever regressing again! Of course, I know that writing that test may be not super easy.

Wim Leers’s picture

Issue tags: +Needs tests
FileSize
63.36 KB

#56:

One issue is that RequestFormatRouteFilter->getDefaultFormat() always returns 'html' if there are multiple routes in the collection.

Well yes, that's the very purpose here.

I have created a new patch which checks if all the routes require the same format.

Oh, interesting! That is theoretically also okay, yes.

But I have to say I don't quite understand that if all those routes match the same path, already support the same method (because method_filter has a higher priority than request_format_route_filter), already accept the same input (because content_type_header_matcher also has a higher priority), and per your explanation they also support the same format! Looking at \Drupal\relaxed\Plugin\rest\resource\ResourceBase, I don't quite understand how this is possible. To understand this, I installed the Relaxed module, but I still don't understand:

Pinged dixon_ for insight. EDIT: the ping: https://twitter.com/wimleers/status/981477876104036352

dawehner’s picture

  1. +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
    @@ -17,7 +17,13 @@ class RequestFormatRouteFilter implements FilterInterface {
    -    $format = $request->getRequestFormat($default_format);
    +    if (is_null($request->getRequestFormat(NULL))) {
    +      $format = $default_format;
    +      $request->setRequestFormat($default_format);
    +    }
    +    else {
    +      $format = $request->getRequestFormat($default_format);
    +    }
    

    It would be nice to document in line, why we need this additional logic.

  2. +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
    @@ -70,14 +78,25 @@ public function filter(RouteCollection $collection, Request $request) {
    +      $iterator->next();
    +      for ($iterator; $iterator->valid(); $iterator->next()) {
    +        $route = $iterator->current();
    

    Is there a reason we don't just use foreach which doesn't require you to use the iteration directly?

  3. +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
    @@ -70,14 +78,25 @@ public function filter(RouteCollection $collection, Request $request) {
    +        if ($required_format != $route->getRequirement('_format')) {
    

    Let's use a strict comparison here :)

polynya’s picture

Thanks for the feedback. I'll try to provide more details later and to look at the large set of test failures for patch #58.

Also jeqq is working on a fix for Relaxed - #2955317-9: The module needs fixes after changes in Drupal 8.5.x core

Wim Leers’s picture

bendev’s picture

Issue summary: View changes

Thank you for your work.

I tested #58 successfully.
Same remark as in #54. I needed to configure Json as the only "Accepted request formats" in Format -> Serializer -> Settings
Now I no longer need to have the _format=json query argument.

dawehner’s picture

Some interesting observations while comparing what happens in 8.4.x and 8.5.x in relaxed:

  • Relaxed overrides \Drupal\multiversion\RouteProvider, but it turns out
    that doesn't matter
  • Relaxed overrides \Drupal\relaxed\Plugin\rest\resource\ResourceBase::routes,
    which means that assumptions might no longer be true
  • Finally \Drupal\rest\Routing\ResourceRoutes::getRoutesForResourceConfig sets the format,
    which was changed in b4a02309bf739ae16ceedda3ff3a181d9a3a9b82. Basically one problem I see is
    ::routes used to be overridable, with b4a02309bf739ae16ceedda3ff3a181d9a3a9b82 more
    logic is living outside.

Summary: I think we should move the relaxed usecase into its own issue, because it is actually a rest module problem,
not a generic routing problem.

polynya’s picture

I have updated patch #57 to remove the iterator and to add some comments.

dgtlmoon’s picture

Hi all, sorry to bump the thread - I'm doing some follow up work on the Drupal 8 REST documentation, according to my current 8.6.x-dev it seems to me that the "_format=" argument is still ALWAYS required - can someone vouch if that is the case?

As it stands, both the config (config/sync/rest.resource.foobarinfo.yml, "default_format: json") and the comment block in my resource handler dont' make any use of default_format

/**
 * Represents foobar test resource.
 *
 * @RestResource(
 *   id = "foobarinfo",
 *   label = @Translation("Foobar Information"),
 *   default_format = "json",
 *   uri_paths = {
 *     "canonical" = "/api/foobar/{foobar}",
 *   },
 * )
 */
class FoobarInfoResource extends ResourceBase {
  public function get($foobar = 0) {
    $record = ['id' => $foobar];
    return new ResourceResponse($record);
  }
}

curl -H "Accept: application/json" http://172.17.0.1/api/foobar/666 will give me a 406
however
curl -H "Accept: application/json" http://172.17.0.1/api/foobar/666?_format=json" will work.

According to the restui configuration, I have only the "JSON" format enabled.

Again, sorry to muddy the waters on this thread.

note: patch from #58 seemed to drop the requirement for _format=, however when more than 1 format are enabled it does not let me choose the response via the "Accept: application/(json|xml|hal_json)" header

mpdonadio’s picture

#68, there are several related issues to _format being required now at the top of the page. Those related issues may turn up a few more. Unfortunately, this is a complex situation. The related issues tackes various aspects: this one is about inbound routing, there is another about paths for view displays, etc.

The background behind all of this is probably best summarized in #2481453: Implement query parameter based content negotiation as alternative to extensions and #2364011: [meta] External caches mix up response formats on URLs where content negotiation is in use, along with the CR in [#2501221]. The TL;DR is that the Accept header is horribly broken across the interwebs and not a reliable way to do content negotiation in a generic manner in core.

dgtlmoon’s picture

@mpdonadio ok great, yeah I'm not too worried about the the actual state of the issue, just to know if this is worth updating the documentation that _format=json type argument should always be added, so that sounds correct to me then, thanks for the reply!

denisomerovic’s picture

Patch from comment #51 worked for me.

Wim Leers’s picture

DrupalCon Nashville happened, and then I was called in to help with critical customer problems. So, alas, we're now a month since we had an RTBC patch in #53… 😞

Based on the assessment @dawehner posted in #66, patches #57+#58+#60+#67 actually belong in a separate issue.

#51 still applies cleanly. But #67 makes the pretty confusing patches #57/#58/#60 simple again: it only updates \Drupal\Core\Routing\RequestFormatRouteFilter::getDefaultFormat(), which is much, much easier to understand again. The interdiff-51-67.txt interdiff is also super simple. Great! But it still doesn't have the necessary test coverage additions. I asked for that in #60, nearly 3 weeks ago. I even spent a long time digging into the relaxed module to try to get to a point where I had multiple route matches, but couldn't.

So … let's add test coverage, then this can be RTBC again!

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community

This looks like a nice and reasonable improvement over the original RTBC patch. I agree that the more specific REST issues need to be addressed in a separate issue Re: REST module.

This then looks like it can be RTBC again (assuming tests pass).

The last submitted patch, 72: 2854543-72-FAIL--51_plus_test.patch, failed testing. View results

Wim Leers’s picture

Yay, #51+test coverage patch is failing, #67+test coverage patch is passing! 🎉

jeqq’s picture

I started to review and test the patch with Relaxed, it's still not working with it, will add details tomorrow when I finish the testing as today I was limited in time.

Thank you @Wim Leers for the work on this!

albertski’s picture

Tested patch from #72 (..test_plus_67..) on a custom POST Resource I created and I was able to post without putting _format=json. Thanks!

jeqq’s picture

Status: Reviewed & tested by the community » Needs review

After testing with Relaxed - some of our routes work without _format, but most of them not.

Are working routes with one required format and only if there is one route in the collection. Also it will work with more than one route in the collection only if they all have one and the same required format.

Which are not working: routes with multiple format requirements e.g. json|mixed and multiple routes in the collection with different format requirements (this is how RequestFormatRouteFilter::getDefaultFormat() works).

One idea might be to use a default format for the routes: in the route defaults to have _format that can be only one format (not multiple like the _format from route requirements). In this case we can check in RequestFormatRouteFilter::getDefaultFormat(), after we check for the required format in the route, for the default format of the route. Having this we have more chances to return other format that html when there is not _format specified in the query and not end with a 406.

This still won't fix all the issues we have with Relaxed and the CouchDB replicator, that because the replicator is relying on the Accept header when getting the response in a specific format, one example would be the rest.relaxed.doc.GET route that has json|mixed format requirement. The CouchDB replicator expects to get the format it needs by sending the specific format in the Accept header ('multipart/mixed' or 'application/json').

@Wim Leers I remember you said that we can't rely on using the Content-Type header, but how about Accept? Can we use it to determine the format and return the response it depending on this header (when it's set)?

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community

@jeqq, I think it was agreed in #66, #72 & #73 that the Relaxed issues need to be addressed in a separate follow-up issue.

Relaxed appears to have very specific requirements which will prevent this fix from landing and "un-breaking" the more standard sites that were broken by this bug fix.

I think the separate problem that needs to be addressed for Relaxed is: "how to do media type negotiation without the _format query parameter." That is beyond the scope of this issue, however.

Wim Leers’s picture

Which are not working: routes with multiple format requirements

It makes sense they can't work: it's impossible to determine a reliable default for them. This is the first time this is being mentioned though.

One idea might be to use a default format for the routes:

We have an issue for exactly that: #2852587: Allow REST resource config entities to specify a default format. Please help push that issue forward :)

that because the replicator is relying on the Accept header

No stable release of Drupal 8 has ever supported this.

I remember you said that we can't rely on using the Content-Type header, but how about Accept?

I never said that; I actually said you can't rely on the Accept header, and that's exactly the thing that D8 has never supported. I also provided the relevant link: https://www.drupal.org/node/2501221. You can implement it for just the RELAXed module routes, assuming they're on paths not shared with no other controllers, and assuming there are never anonymous, non-HTTPS requests/responses. See \Drupal\accept_header_routing_test\Routing\AcceptHeaderMatcher. Or, you can do something like what the JSON API module did: a hardcoded path prefix which can be checked very early in request processing, which can then be used to set the appropriate format: #2831137: Remove the need for ?_format=api_json: assume the use of the 'api_json' format for routes managed by JSON API.

jeqq’s picture

I agree with #79.

We have an issue for exactly that: #2852587: Allow REST resource config entities to specify a default format

Wll take a look.

I never said that; I actually said you can't rely on the Accept header, and that's exactly the thing that D8 has never supported. I also provided the relevant link: https://www.drupal.org/node/2501221

I wanted to check that but couldn't find the link. :)

Will also check the solutions Wim proposed. Thanks!

Wim Leers’s picture

Cool :) Thanks for being so very diligent, @jeqq! And I'm sorry for being a bit impatient — it's hard to walk the line/find the balance between helping dozens, likely hundreds, possibly thousands of sites using the core rest module ASAP and also covering the needs of the contrib relaxed module. Thanks for being so understanding, and for helping us arrive at a better solution!

larowlan’s picture

Couple of minor observations

  1. +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
    @@ -70,14 +79,22 @@ public function filter(RouteCollection $collection, Request $request) {
    +    $routes = array_values($collection->all());
    +
    +    $required_format = $routes[0]->getRequirement('_format');
    

    is there a chance that RouteCollection could be empty here? i.e should we be checking that [0] exists before we call methods on it? (defensive programming)

  2. +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
    @@ -70,14 +79,22 @@ public function filter(RouteCollection $collection, Request $request) {
    +    if (strpos($required_format, '|') !== FALSE) {
    

    should me make an explicit protected method for this?

    
    self::supportsMultipleFormats($requiredFormat)
    
    

    Would be more obvious what is going on? Could be public - I'm guessing we have the same logic in multiple places

  3. +++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
    @@ -70,14 +79,22 @@ public function filter(RouteCollection $collection, Request $request) {
    +    for ($i = 1; $i < count($routes); $i++) {
    +      if ($required_format != $routes[$i]->getRequirement('_format')) {
    

    any reason why we didn't use foreach here? Would aid readability.

catch’s picture

Status: Reviewed & tested by the community » Needs review

Bumping back to CNR for those points.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
1.97 KB
15.64 KB
  1. No: \Symfony\Cmf\Component\Routing\NestedMatcher\NestedMatcher::matchRequest() throws ResourceNotFoundException if the route collection is empty.
  2. Indeed, we have similar logic in many places. This is honestly a weakness in the Symfony routing system. Examples aplenty when you grep for format.*'\|' in *.php files.
  3. Agreed. Thank you for making this remark! 🙏

For points 2 + 3, the easiest solution was to almost completely refactor @polynya's code, but retain the same fundamental logic. An array_reduce() to get a list of all formats of all routes in the collection, then turn it into a set, if the set contains only one format, then we can use that as the default. Sounds simple enough hopefully? :) Much smaller too.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 85: 2854543-85.patch, failed testing. View results

dawehner’s picture

For points 2 + 3, the easiest solution was to almost completely refactor @polynya's code, but retain the same fundamental logic. An array_reduce() to get a list of all formats of all routes in the collection, then turn it into a set, if the set contains only one format, then we can use that as the default. Sounds simple enough hopefully? :) Much smaller too.

The new code looks quite a bit like you started to learn functional programming :)

+++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
@@ -69,15 +79,16 @@ public function filter(RouteCollection $collection, Request $request) {
+    $formats = array_unique(array_filter($all_formats));
...
+      ? $formats[0]

I think you better use reset() here. array_filter doesn't reset keys, see

 print_r(array_unique(array_filter(['', 4, 2, 3, 2, 3])));
Array
(
    [1] => 4
    [2] => 2
    [3] => 3
)
Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
1.32 KB
15.82 KB

Hah, I just was working on an updated patch, and noticed the same :) Did I mention I very much miss a Set data structure in PHP? :(

The new code looks quite a bit like you started to learn functional programming :)

Is this meant as "yay FP" or "you're obviously getting started with FP"? I'm fine with either/both, just curious now :D I've been doing this style of logic for this kind of problem for a while now.

dawehner’s picture

Did I mention I very much miss a Set data structure in PHP? :(

I'm sitting in a corner crying everyday that the JS Set doesn't implement union and diffs, but yeah at least it implements the Set semantics :)

Is this meant as "yay FP" or "you're obviously getting started with FP"? I'm fine with either/both, just curious now :D I've been doing this style of logic for this kind of problem for a while now.

I made the experience that many problems solved in functional style has less ugs than solved in imperative way. You can decide for hay or nay :)

vivify’s picture

Edit: Nevermind. I assumed latest patch 88 contained all previous code.

Patch only worked on the preview for me. I preview with contextual filters, go to the path provided in the preview. Page is a 406, but shows json in the preview.

alphex’s picture

Applying patch #88 with composer is generating bad files.

--- /dev/null
+++ b/core/modules/system/tests/modules/default_format_test/default_format_test.info.yml

creates the directory b/core in the .../core directory.

larowlan’s picture

@alphex that is a reported issue in the latest version of composer-patches

larowlan’s picture

Adding review credits

  • larowlan committed 41dfad3 on 8.6.x
    Issue #2854543 by Wim Leers, polynya, dawehner, mpdonadio, gabesullice,...

  • larowlan committed 2b331d0 on 8.6.x
    Issue #2854543 by Wim Leers, polynya, dawehner, mpdonadio, gabesullice,...

  • larowlan committed 31f0612 on 8.5.x
    Issue #2854543 by Wim Leers, polynya, dawehner, mpdonadio, gabesullice,...
  • larowlan committed 3f955a3 on 8.5.x
    Issue #2854543 by Wim Leers, polynya, dawehner, mpdonadio, gabesullice,...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Fixed on commit

diff --git a/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
index 7448e6f1ef..8d14dd52cf 100644
--- a/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
+++ b/core/lib/Drupal/Core/Routing/RequestFormatRouteFilter.php
@@ -85,7 +85,7 @@ protected static function getDefaultFormat(RouteCollection $collection) {
       $route_formats = !$route->hasRequirement('_format')
         ? ['html']
         : explode('|', $route->getRequirement('_format'));
-      return array_merge($carry,$route_formats);
+      return array_merge($carry, $route_formats);
     }, []);
     $formats = array_unique(array_filter($all_formats));

Committed as 41dfad3 and pushed to 8.6.x.
Cherry-picked as 3f955a3 and pushed to 8.5.x.

Status: Fixed » Closed (fixed)

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

b.livonnen’s picture

Not working for a pure headless implementation (8.5.5)

I had to put 'json' as the default format

// The default format is 'html' unless ALL routes require the same format.
return count($formats) === 1
? reset($formats)
: 'html';
return count($formats) === 1
 ? reset($formats)
: 'json';
Wim Leers’s picture

@b.livonnen: Your "pure headless" routes are probably not specifying a _format: json route requirement.