element-api icon indicating copy to clipboard operation
element-api copied to clipboard

JS fetch request works from Craft frontend, but not from a different subdomain

Open Michael-Paragonn opened this issue 2 years ago • 1 comments

Description

A client has an Element API endpoint set up to return quick "as you type" search results. This works beautifully from the Craft site (https://example.com).

The client also has a subdomain powered by HubSpot (https://learn.example.com), and they want the same functionality across both sites. But when I copy over the same exact JS, the returned response is supposedly a 404, even though the JSON payload is identical.

Any ideas on why it's not working from a subdomain? I do have Access-Control-Allow-Origin defined properly, and I do see the difference it makes when it's there vs not there...

element-api.php

<?php
use craft\elements\Entry;

return
[
    'endpoints' =>
    [
        'api/search' => function()
        {
            Craft::$app->getResponse()->getHeaders()->set('Access-Control-Allow-Origin', 'https://learn.example.com');
            Craft::$app->getResponse()->getHeaders()->set('Access-Control-Allow-Credentials', true);

            // settings
            $section_handle = 'about, blog, caseStudies, industries, services, work';
            $phrase = Craft::$app->request->getParam('query');

            $criteria = [
                'section' => $section_handle,
                'limit' => 5,
                'orderBy' => 'score',
                'search' => $phrase,

            ];

            return [
                'elementType' => Entry::class,
                'criteria' => $criteria,
                'paginate' => false,
                'transformer' => function(craft\elements\Entry $entry)
                {

                    return [
                        'title' => $entry->title,
                        'url' => $entry->section->handle == 'blog' ? $entry->permalink : $entry->url,
                        'section' => $entry->section->name,
                    ];
                },

            ];
        },
    ]
];

JS search functionality (on the HubSpot subdomain site):

function delay(fn, ms)
{
    let timer = 0
    return function(...args)
    {
        clearTimeout(timer)
        timer = setTimeout(fn.bind(this, ...args), ms || 0)
    }
}
let searchAutocompleteInputs = document.querySelectorAll('.jsSearchAutocomplete');
searchAutocompleteInputs.forEach(input =>
{
    let autocompleteId = input.dataset.list;
    let autocomplete = document.getElementById(autocompleteId);
    input.addEventListener('input', delay( function(e)
    {
        let keyword = e.target.value;
        if (keyword)
        {
            fetch('https://example.com/api/search' + `?query=${keyword}`, {mode: 'cors'})
            .then((response) =>
            {
                console.log({response});
                if (response.ok)
                {
                    return response.json();
                }
                throw new Error('Something went wrong with the fetch');
            })
            .then( (responseJson) =>
            {
                               // blah blah...
            })
            .catch((error) =>
            {
                console.warn(error);
            });
        }
        // Empty search field
        else
        {
            autocomplete.innerHTML = '';
        }
    }, 500));
});

"404" response from Hubspot site:

# General
- Request URL: https://example.com/api/search?query=logo
- Request Method: GET
- Status Code: 404
- Remote Address: [redacted]:443
- Referrer Policy: no-referrer-when-downgrade
# Response Headers
- Access-Control-Allow-Credentials: 1
- Access-Control-Allow-Origin: https://learn.example.com
- Cf-Cache-Status: DYNAMIC
- Cf-Ray: 7f43074238c72bca-FRA
- Content-Encoding: br
- Content-Type: application/json; charset=UTF-8
- Date: Wed, 09 Aug 2023 21:17:52 GMT
- Link: <https://example.com/api/search>; rel="canonical"
- Nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
- Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=blahblahblah"}],"group":"cf-nel","max_age":604800}
- Server: cloudflare
- Vary: Accept-Encoding
- X-Powered-By: Craft CMS
- X-Robots-Tag: none
# Request Headers
- :authority: example.com
- :method: GET
- :path: /api/search?query=logo
- :scheme: https
- Accept: */*
- Accept-Encoding: gzip, deflate, br
- Accept-Language: en-US,en;q=0.9,he;q=0.8,ar;q=0.7
- Origin: https://learn.example.com
- Referer: https://learn.example.com/?hsDebug=true
- Sec-Ch-Ua: "Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"
- Sec-Ch-Ua-Mobile: ?0
- Sec-Ch-Ua-Platform: "Windows"
- Sec-Fetch-Dest: empty
- Sec-Fetch-Mode: cors
- Sec-Fetch-Site: same-site
- User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36

Valid response from the Craft site:

# General
- Request URL: https://example.com/api/search?query=logo
- Request Method: GET
- Status Code: 200
- Remote Address: [redacted]:443
- Referrer Policy: strict-origin-when-cross-origin
# Response Headers
- Access-Control-Allow-Credentials: 1
- Access-Control-Allow-Origin: https://learn.example.com
- Cache-Control: no-store, no-cache, must-revalidate
- Cf-Cache-Status: DYNAMIC
- Cf-Ray: 7f4301de1ad82bc1-FRA
- Content-Encoding: br
- Content-Type: application/json; charset=UTF-8
- Date: Wed, 09 Aug 2023 21:14:11 GMT
- Expires: Thu, 19 Nov 1981 08:52:00 GMT
- Link: <https://example.com/api/search>; rel="canonical"
- Nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
- Pragma: no-cache
- Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=blahblah"}],"group":"cf-nel","max_age":604800}
- Server: cloudflare
- Set-Cookie: blahblah expires=Wed, 23-Aug-2023 21:14:11 GMT; Max-Age=1209600; path=/; secure; HttpOnly
- Vary: Accept-Encoding
- X-Debug-Duration: 211
- X-Debug-Link: https://example.com/actions/debug/default/view?tag=64d401a3b341e
- X-Debug-Tag: 64d401a3b341e
- X-Powered-By: Craft CMS
- X-Robots-Tag: none
# Request Headers
- :authority: example.com
- :method: GET
- :path: /api/search?query=logo
- :scheme: https
- Accept: */*
- Accept-Encoding: gzip, deflate, br
- Accept-Language: en-US,en;q=0.9,he;q=0.8,ar;q=0.7
- Cookie: blahblah
- Referer: https://example.com/
- Sec-Ch-Ua: "Not/A)Brand";v="99", "Google Chrome";v="115", "Chromium";v="115"
- Sec-Ch-Ua-Mobile: ?0
- Sec-Ch-Ua-Platform: "Windows"
- Sec-Fetch-Dest: empty
- Sec-Fetch-Mode: cors
- Sec-Fetch-Site: same-origin
- User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36

Additional info

  • Craft version: Craft CMS 3.7.55.2
  • PHP version: 7.4.33
  • Database driver & version: MySQL 8.0.30
  • Plugins & versions:
    • Advanced Image Field 1.0.0
    • Button Box 3.1.1
    • Contact Form 2.5.1
    • Contact Form Honeypot 1.0.3
    • Control Panel CSS 2.4.0
    • Digital Download 2.1.9
    • DigitalOcean Spaces Volume 1.1.3
    • Element API 2.8.6.1
    • Feed Me 4.6.3.1
    • Field Manager 2.2.5
    • Forms 3.13.20
    • Guest Entries 2.4.0
    • Imager X v3.6.5
    • Imager X Storage Driver for DigitalOcean Spaces 2.0.0
    • Inventory 2.1.1
    • [Custom Plugin]1.0.0
    • Mix 1.5.2
    • Neo 2.13.15
    • oEmbed 1.3.18
    • Postmark 2.1.0
    • Redactor 2.10.10
    • Redactor Custom Styles 3.0.4
    • Redirects 3.2.10
    • Scout 2.7.2
    • SEO 3.7.4
    • Sprout Email 4.4.10
    • Super Table 2.7.3
    • Typed link field 1.0.25

Michael-Paragonn avatar Aug 09 '23 21:08 Michael-Paragonn

Bump! I'm still experiencing this issue, now under Craft 4. Any takers?

Michael-Paragonn avatar Oct 14 '24 17:10 Michael-Paragonn

@brandonkelly Ping! 🙂

Michael-Paragonn avatar Oct 20 '24 19:10 Michael-Paragonn

Just tested this locally, and as long as I have access-control-allow-origin: * in the response headers, it’s working fine on my end.

As of Craft 5.3, you can configure Craft to send CORS headers by adding the following to config/app.php (at the top level of the return array):

return [
    // ...
    'as corsFilter' => craft\filters\Cors::class,
];

Or if you want to specify certain allowed origins:

return [
    // ...
    'as corsFilter' => [
        'class' => craft\filters\Cors::class,
        'cors' => [
            'Origin' => [
                'https://learn.example.com',
            ],
            'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
            'Access-Control-Request-Headers' => ['*'],
            'Access-Control-Allow-Credentials' => true,
            'Access-Control-Max-Age' => 86400,
            'Access-Control-Expose-Headers' => [],
        ],
    ],
];

brandonkelly avatar Jan 20 '25 18:01 brandonkelly