Cookies do not work on cross-domain requests over AJAX within the browser, immediately breaking the default cart management model using Drupal’s session storage. You can perform POST operations to add products to the cart, and it'll work. But you will get a new cart each time. In fact, you can then browse the backend site and even checkout with the latest order.

I am proposing the Cart API module provides an alternative cart session implementation that supports working with the Cart API in a fully decoupled situation. As it stands this module only works in progressively decoupled scenarios or when cross-domain cookies can work (ie: shared root domain, or something like that.)

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

mglaman created an issue. See original summary.

mglaman’s picture

FileSize
4.37 KB

This needs some testing. To enable, alter your site's sites/default/services.yml. Enable the new service:

parameters:
  commerce_cart_api.token_cart_session: true

Rebuild your cache.

I was able to use Postman to add an item to my cart and retrieve it without cookies or a session. Here I used a token called fruitloops. Normally it'd be a token, like the one from /session/token

Add to cart

curl -X POST \
  'http://commerce2x.ddev.local/cart/add?_format=json' \
  -H 'Content-Type: application/json' \
  -H 'X-Cart-Token: fruitloops' \
  -d '[
          {
            "purchased_entity_type": "commerce_product_variation",
            "purchased_entity_id": 49,
            "quantity": 1
          }
        ]'

Carts collection

curl -X GET \
  'http://commerce2x.ddev.local/cart?_format=json' \
  -H 'Postman-Token: 595417e3-5448-4078-a5a9-c070989888c4' \
  -H 'X-Cart-Token: fruitloops'

Response (after two add to carts)

[
    {
        "order_id": 18,
        "uuid": "81b9ed25-f7dd-41d5-b82e-35dbe5ccb327",
        "order_number": null,
        "store_id": 1,
        "total_price": {
            "number": "50.000000",
            "currency_code": "USD",
            "formatted": "$50.00"
        },
        "order_items": [
            {
                "order_item_id": 20,
                "uuid": "127217a5-bb80-40d2-b7fe-90cb338f5b92",
                "order_id": 18,
                "purchased_entity": {
                    "variation_id": 49,
                    "uuid": "47b14b42-b9ab-470d-9a7b-93f2aeb3760b",
                    "type": "simple",
                    "product_id": 44,
                    "sku": "UT-KnitHat-MNPlum",
                    "title": "Knit Hat in Midnight Plum",
                    "list_price": {
                        "number": null,
                        "currency_code": null
                    },
                    "price": {
                        "number": "25.000000",
                        "currency_code": "USD",
                        "formatted": "$25.00"
                    },
                    "field_images": [
                        51
                    ],
                    "weight": {
                        "number": "1.000000",
                        "unit": "kg"
                    }
                },
                "title": "Knit Hat in Midnight Plum",
                "quantity": "2.00",
                "unit_price": {
                    "number": "25.000000",
                    "currency_code": "USD",
                    "formatted": "$25.00"
                },
                "total_price": {
                    "number": "50.000000",
                    "currency_code": "USD",
                    "formatted": "$50.00"
                }
            }
        ]
    }
]
mglaman’s picture

The only problem is this: we do not have a checkout API, and you couldn't redirect to the checkout form and proceed with the order unless you setup a route to "claim" an order. That's also kind of hard because \Drupal\commerce_checkout\Controller\CheckoutController::checkAccess dips into the Cart Session class. The header will not always be present.

We could pass the token as a query parameter, but that would disappear after the first request. What would be nice is if there was a way to support the PHP session based storage and a token-based one. This way you could have a fully decoupled frontend that linked to your backend (directly, iframe, etc) for checkout.

The private.tempstore requires a session

  protected function getOwner() {
    $owner = $this->currentUser->id();
    if ($this->currentUser->isAnonymous()) {
      $this->startSession();
....

  protected function startSession() {
    $has_session = $this->requestStack
      ->getCurrentRequest()
      ->hasSession();
    if (!$has_session) {
      /** @var \Symfony\Component\HttpFoundation\Session\SessionInterface $session */
      $session = \Drupal::service('session');
      $this->requestStack->getCurrentRequest()->setSession($session);
      $session->start();
    }
  }

\Drupal\commerce_cart_api\TokenCartSession could extend or decorate the default \Drupal\commerce_cart\CartSession. Each method could ensure the session is populated.

The session cookie is not transmitted during XHR requests, but it is still respected on the server. This might allow for fully decoupled cart management but still enable using the coupled checkout form.

mglaman’s picture

Thanks to gabesullice who pointed me to https://tools.ietf.org/html/rfc6648. The header should be renamed something like Commerce-Cart-Token.

mglaman’s picture

Status: Active » Needs review
FileSize
5.71 KB

Okay, changed the service parameter to:

parameters:
  commerce_cart_api:
    use_token_cart_session: false

This now decorates the core service. Should allow working with fully decoupled and preserve progressively decoupled if enabled. Also the idea of cross-origin domain requests but a single checkout form.

Status: Needs review » Needs work

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

mglaman’s picture

This still does not work, as the private tempstore relies on a session, which cannot be tracked. Add to cart is still working.. but fetching the carts via the token does not work.

When directly fetching the cart resource in browser I can get

[{"order_id":35,"uuid":"9a7807f4-132e-4a42-a52c-9b63d979e650","order_number":null,"store_id":1,"total_price":{"number":"299.940000","currency_code":"USD","formatted":"$299.94"},"order_items":[{"order_item_id":265,"uuid":"843f3eea-6c24-487e-b3b7-8b0f42786e4b","order_id":35,"purchased_entity":{"variation_id":36,"uuid":"af1f9528-cca6-4034-afc6-928adcc2e31e","type":"simple","product_id":31,"sku":"PT-GiganticFlamingo-001","title":"Gigantic Inflatable Flamingo","list_price":{"number":null,"currency_code":null},"price":{"number":"49.990000","currency_code":"USD","formatted":"$49.99"},"field_images":[38],"weight":{"number":"1.000000","unit":"kg"}},"title":"Gigantic Inflatable Flamingo","quantity":"6.00","unit_price":{"number":"49.990000","currency_code":"USD","formatted":"$49.99"},"total_price":{"number":"299.940000","currency_code":"USD","formatted":"$299.94"}}]}]

But over AJAX it is empty.

mglaman’s picture

Status: Needs work » Needs review
FileSize
5.7 KB

Using the shared tempstore did the trick! All endpoints were cached behind page_cache or internal_page_cache and busted appropriately. I was then able to click the link to enter checkout, such as http://commerce2x-api.ddev.local/checkout/36 and purchase my products.

Status: Needs review » Needs work

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

mglaman’s picture

There is one problem: when a new cart is created, the cart collection cache does not bust. This is only noticeable when page_cache is active, which normally is not present when a session is activated.

When setting the following to disable the page_cache, everything works fine.

$settings['container_yamls'][] = DRUPAL_ROOT . '/sites/development.services.yml';
$settings['cache']['bins']['page'] = 'cache.backend.null';

The cart collection's cache information is:

X-Drupal-Cache-Contexts: cart store
X-Drupal-Cache-Tags: commerce_order:38 config:rest.resource.commerce_cart_collection config:rest.settings http_response

page_cache does not respect contexts. Whatever tag is invalidated when an order is updated needs to be attached to the response. However, that would make it impossible to cache that endpoint.

Entities by default have the following cache tag: ENTITY_TYPE_ID:ENTITY_ID

  /**
   * {@inheritdoc}
   */
  public function getCacheTagsToInvalidate() {
    // @todo Add bundle-specific listing cache tag?
    //   https://www.drupal.org/node/2145751
    if ($this->isNew()) {
      return [];
    }
    return [$this->entityTypeId . ':' . $this->id()];
  }

  /**
   * {@inheritdoc}
   */
  public function getCacheTags() {
    if ($this->cacheTags) {
      return Cache::mergeTags($this->getCacheTagsToInvalidate(), $this->cacheTags);
    }
    return $this->getCacheTagsToInvalidate();
  }
mglaman’s picture

Status: Needs work » Needs review
FileSize
7.51 KB

This adds a page_cache_response_policy service to deny the cart collection from being cache under page_cache.

mglaman’s picture

When testing this using a decoupled site:

  • Chrome: add to cart, click link to API_DOMAIN/checkout/{order_id}: OK, can checkout.
  • Firefox: add to cart, click link to API_DOMAIN/checkout/{order_id}: OK, can checkout.
  • Brave: add to cart, click link to API_DOMAIN/checkout/{order_id}: FAILURE, access denied, cannot checkout.
  • Safari: add to cart, click link to API_DOMAIN/checkout/{order_id}: FAILURE, access denied, cannot checkout.

If that is the case, it is worth removing the cart session decoration and just full on replacing it, and providing details on how to create an "order claim" controller.

Status: Needs review » Needs work

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

mglaman’s picture

Status: Needs work » Needs review
FileSize
9.96 KB

This patch adds an event subscriber which allows passing ?cartToken to convert the token cart data to the user's session.

Status: Needs review » Needs work

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

Wim Leers’s picture

Issue tags: +API-First Initiative

What would be nice is if there was a way to support the PHP session based storage and a token-based one.

I'm not 100% certain what this is saying, but: look at \Drupal\Core\Session\SessionConfigurationInterface::hasSession() — and especially at the default implementation in \Drupal\Core\Session\SessionConfiguration::hasSession():

  public function hasSession(Request $request) {
    return $request->cookies->has($this->getName($request));
  }

You could decorate core's service and do:

  public function hasSession(Request $request) {
    return $this->decorated->hasSession($request) || $request->headers->has('X-Cart-Token');
  }
the cart collection cache does not bust. This is only noticeable when page_cache is active, which normally is not present when a session is activated.

It's \Drupal\Core\PageCache\RequestPolicy\NoSessionOpen that ensures Page Cache ignores responses to requests that have sessions, and it calls … you guessed it, \Drupal\Core\Session\SessionConfigurationInterface::hasSession(), to determine whether a request has a session! So the above would fix this too.

mglaman’s picture

FileSize
9.6 KB

This takes the advice from #16 and decorates session_configuration so that the Commerce-Cart-Token header or cartToken query parameter additional specify if a session is active.

  public function hasSession(Request $request) {
    return $this->decorated->hasSession($request) || 
        $request->headers->has(CartTokenSession::HEADER_NAME) || 
        $request->query->has(CartTokenSession::QUERY_NAME);
  }
Wim Leers’s picture

Does it work as expected/hoped? :)

mglaman’s picture

It does! The session is definitely being activated and page_cache is ignored. I'm having some weird problems on my remote for the entire process, but that's something else I think.

mglaman’s picture

FileSize
9.61 KB

Bah, I had a horrible subscribed events definition in my event subscriber.

mglaman’s picture

FileSize
9.74 KB

CartTokenClaimSubscriber needs to run before RouterListener which executes routing access checks.

mglaman’s picture

Status: Needs work » Needs review

HEAD fixed. Retesting

mglaman’s picture

Status: Needs review » Needs work

Commerce-Cart-Token needs to be added to the Vary header.

mglaman’s picture

Status: Needs work » Needs review
FileSize
18.47 KB

Adding a Vary that should bust reverse proxy caches if Drupal has a max page cache configured.

mglaman’s picture

FileSize
10.38 KB

Bad patch, re-do!

mglaman’s picture

FileSize
12.43 KB

Patch with clean up for commit.

  • mglaman committed a317347 on 8.x-1.x
    Issue #3036585 by mglaman: Provide cart session management without user...
mglaman’s picture

Status: Needs review » Fixed

🙌working like a charm.

Wim Leers’s picture

Lovely! :D

  1. +++ b/src/CartTokenSession.php
    @@ -0,0 +1,151 @@
    +final class CartTokenSession implements CartSessionInterface {
    

    final 👍

  2. +++ b/src/CartTokenSession.php
    @@ -0,0 +1,151 @@
    +  private $inner;
    

    private 👍

  3. +++ b/src/CartTokenSession.php
    @@ -0,0 +1,151 @@
    +   * @param \Drupal\commerce_cart\CartSessionInterface $inner
    +   *   The decorated cart session.
    

    Service decoration. 👍

  4. +++ b/src/Session/CartTokenSessionConfiguration.php
    @@ -0,0 +1,48 @@
    +  public function __construct(SessionConfigurationInterface $decorated) {
    +    $this->decorated = $decorated;
    +  }
    ...
    +  public function hasSession(Request $request) {
    +    return $this->decorated->hasSession($request) || $request->headers->has(CartTokenSession::HEADER_NAME) || $request->query->has(CartTokenSession::QUERY_NAME);
    +  }
    

    A decorated session configuration service 👍

My thumb hurts now 😆

P.S.: issue credit would be cool :)

mglaman’s picture

P.S.: issue credit would be cool :)

D'oh! I thought I checked the box.

ayalon’s picture

I tried this new feature:
- I had to add a service parameter to activate the feature, which is undocumented
- I have huge performance problems. Getting a cart takes multiple seconds on a plain new system
- I sometime have lock errors.

Any ideas what could be the problem?

ayalon’s picture

I'm pretty sure the current code only supports token in query strings. Header is documented but the code does not cover it:

  public function onRequest(GetResponseEvent $event) {
    $cart_token = $event->getRequest()->query->get(CartTokenSession::QUERY_NAME);
    if ($cart_token) {
      $token_cart_data = $this->tempStore->get($cart_token);
      foreach ([CartSessionInterface::ACTIVE, CartSessionInterface::COMPLETED] as $cart_type) {
        if (isset($token_cart_data[$cart_type]) && is_array($token_cart_data[$cart_type])) {
          foreach ($token_cart_data[$cart_type] as $token_cart_datum) {
            $this->cartSession->addCartId($token_cart_datum, $cart_type);
          }
        }
      }
    }
  }
ayalon’s picture

Status: Fixed » Needs review
mglaman’s picture

Status: Needs review » Fixed

The code in #32 only exists when linking to the checkout via query parameter.

Cart token is fully supported and implemented on https://commerce-demo-decoupled.firebaseapp.com/. Reference here: https://github.com/mglaman/commerce_demo_decoupled

Can you open a new issue for your specific problem and we can track it there?

---

- I had to add a service parameter to activate the feature, which is undocumented

I thought I added that to https://www.drupal.org/docs/8/modules/commerce-cart-api/cart-token-sessions, but I did not. :) Sorry

- I have huge performance problems. Getting a cart takes multiple seconds on a plain new system
- I sometime have lock errors.

That's.. odd. The backend serving the demo has minimal setup and no additional performance items.

ayalon’s picture

Thanks. I was able to solve most of the problems. For an unknown reason, I had 15'000 copies of the same order ID in the tempstore.

Will create a new story for my questions.

mglaman’s picture

I had 15'000 copies of the same order ID in the tempstore.

😱That would do it!

This is definitely a more experimental feature, which is why it is off by default. So far it has been working for demos and initial "production" like usage. It would be worth investigating how the tempstore exploded.

Status: Fixed » Closed (fixed)

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