diff --git a/.changeset/serious-crabs-chew.md b/.changeset/serious-crabs-chew.md new file mode 100644 index 00000000..c22b5723 --- /dev/null +++ b/.changeset/serious-crabs-chew.md @@ -0,0 +1,16 @@ +--- +'@shopify/hydrogen-react': patch +--- + +Adds the functions `getStorefrontApiUrl()` and `getPublicTokenHeaders()` to the object returned by `useShop()` (and provided by ``). + +For example: + +```ts +const {storefrontId, getPublicTokenHeaders, getStorefrontApiUrl} = useShop(); + +fetch(getStorefrontApiUrl(), { + headers: getPublicTokenHeaders({contentType: 'json'}) + body: {...} +}) +``` diff --git a/packages/react/src/CartProvider.stories.tsx b/packages/react/src/CartProvider.stories.tsx index c290774e..bd04fa0d 100644 --- a/packages/react/src/CartProvider.stories.tsx +++ b/packages/react/src/CartProvider.stories.tsx @@ -1,7 +1,7 @@ import {ComponentProps, useState} from 'react'; import type {Story} from '@ladle/react'; import {CartProvider, storageAvailable, useCart} from './CartProvider.js'; -import {ShopifyContextValue, ShopifyProvider} from './ShopifyProvider.js'; +import {type ShopifyContextProps, ShopifyProvider} from './ShopifyProvider.js'; import {CART_ID_STORAGE_KEY} from './cart-constants.js'; const merchandiseId = 'gid://shopify/ProductVariant/41007290482744'; @@ -208,7 +208,7 @@ function CartComponent() { ); } -const config: ShopifyContextValue = { +const config: ShopifyContextProps = { storeDomain: 'hydrogen-preview.myshopify.com', storefrontToken: '3b580e70970c4528da70c98e097c2fa0', storefrontApiVersion: '2022-10', diff --git a/packages/react/src/ShopifyProvider.stories.tsx b/packages/react/src/ShopifyProvider.stories.tsx index d6a21ac9..6617c186 100644 --- a/packages/react/src/ShopifyProvider.stories.tsx +++ b/packages/react/src/ShopifyProvider.stories.tsx @@ -1,9 +1,8 @@ -import * as React from 'react'; import type {Story} from '@ladle/react'; import { ShopifyProvider, useShop, - type ShopifyContextValue, + type ShopifyContextProps, } from './ShopifyProvider.js'; const Template: Story<{ @@ -11,7 +10,7 @@ const Template: Story<{ storefrontToken: string; version: string; }> = ({storeDomain, storefrontToken, version}) => { - const config: ShopifyContextValue = { + const config: ShopifyContextProps = { storeDomain, storefrontToken, storefrontApiVersion: version, diff --git a/packages/react/src/ShopifyProvider.test.tsx b/packages/react/src/ShopifyProvider.test.tsx index 5a0ac29c..4f48ba7d 100644 --- a/packages/react/src/ShopifyProvider.test.tsx +++ b/packages/react/src/ShopifyProvider.test.tsx @@ -1,13 +1,12 @@ -import * as React from 'react'; import {render, screen, renderHook} from '@testing-library/react'; import { ShopifyProvider, useShop, - type ShopifyContextValue, + type ShopifyContextProps, } from './ShopifyProvider.js'; import type {PartialDeep} from 'type-fest'; -const SHOPIFY_CONFIG: ShopifyContextValue = { +const SHOPIFY_CONFIG: ShopifyContextProps = { storeDomain: 'notashop.myshopify.com', storefrontToken: 'abc123', storefrontApiVersion: '2022-10', @@ -47,10 +46,108 @@ describe('', () => { expect(result.current.storeDomain).toBe('notashop.myshopify.com'); }); + + describe(`getStorefrontApiUrl()`, () => { + it(`returns the correct values`, () => { + const {result} = renderHook(() => useShop(), { + wrapper: ({children}) => ( + + {children} + + ), + }); + + expect(result.current.getStorefrontApiUrl()).toBe( + 'https://notashop.myshopify.com/api/2022-10/graphql.json' + ); + }); + + it(`allows overrides`, () => { + const {result} = renderHook(() => useShop(), { + wrapper: ({children}) => ( + + {children} + + ), + }); + + expect( + result.current.getStorefrontApiUrl({ + storeDomain: 'override.myshopify.com', + storefrontApiVersion: '2022-07', + }) + ).toBe('https://override.myshopify.com/api/2022-07/graphql.json'); + }); + }); + + describe(`getPublicTokenHeaders()`, () => { + it(`returns the correct values`, () => { + const {result} = renderHook(() => useShop(), { + wrapper: ({children}) => ( + + {children} + + ), + }); + + expect( + result.current.getPublicTokenHeaders({contentType: 'json'}) + ).toEqual({ + 'X-SDK-Variant': 'hydrogen-ui', + 'X-SDK-Variant-Source': 'react', + 'X-SDK-Version': '2022-10', + 'X-Shopify-Storefront-Access-Token': 'abc123', + 'content-type': 'application/json', + }); + }); + + it(`allows overrides`, () => { + const {result} = renderHook(() => useShop(), { + wrapper: ({children}) => ( + + {children} + + ), + }); + + expect( + result.current.getPublicTokenHeaders({ + contentType: 'graphql', + storefrontToken: 'newtoken', + }) + ).toEqual({ + 'X-SDK-Variant': 'hydrogen-ui', + 'X-SDK-Variant-Source': 'react', + 'X-SDK-Version': '2022-10', + 'X-Shopify-Storefront-Access-Token': 'newtoken', + 'content-type': 'application/graphql', + }); + }); + }); }); export function getShopifyConfig( - config: PartialDeep = {} + config: PartialDeep = {} ) { return { country: { diff --git a/packages/react/src/ShopifyProvider.tsx b/packages/react/src/ShopifyProvider.tsx index ac669a9a..027f6b7c 100644 --- a/packages/react/src/ShopifyProvider.tsx +++ b/packages/react/src/ShopifyProvider.tsx @@ -1,6 +1,7 @@ import {createContext, useContext, useMemo, type ReactNode} from 'react'; import type {LanguageCode, CountryCode, Shop} from './storefront-api-types.js'; import {SFAPI_VERSION} from './storefront-api-constants.js'; +import {getPublicTokenHeadersRaw} from './storefront-client.js'; const ShopifyContext = createContext({ storeDomain: 'test.myshopify.com', @@ -13,6 +14,12 @@ const ShopifyContext = createContext({ isoCode: 'EN', }, locale: 'EN-US', + getStorefrontApiUrl() { + return ''; + }, + getPublicTokenHeaders() { + return {}; + }, }); /** @@ -23,7 +30,7 @@ export function ShopifyProvider({ shopifyConfig, }: { children: ReactNode; - shopifyConfig: ShopifyContextValue; + shopifyConfig: ShopifyContextProps; }) { if (!shopifyConfig) { throw new Error( @@ -37,13 +44,26 @@ export function ShopifyProvider({ ); } - const finalConfig = useMemo( - () => ({ + const finalConfig = useMemo(() => { + const storeDomain = shopifyConfig.storeDomain.replace(/^https?:\/\//, ''); + return { ...shopifyConfig, - storeDomain: shopifyConfig.storeDomain.replace(/^https?:\/\//, ''), - }), - [shopifyConfig] - ); + storeDomain, + getPublicTokenHeaders(overrideProps) { + return getPublicTokenHeadersRaw( + overrideProps.contentType, + shopifyConfig.storefrontApiVersion, + overrideProps.storefrontToken ?? shopifyConfig.storefrontToken + ); + }, + getStorefrontApiUrl(overrideProps) { + return `https://${overrideProps?.storeDomain ?? storeDomain}/api/${ + overrideProps?.storefrontApiVersion ?? + shopifyConfig.storefrontApiVersion + }/graphql.json`; + }, + }; + }, [shopifyConfig]); return ( @@ -66,7 +86,7 @@ export function useShop() { /** * Shopify-specific values that are used in various Hydrogen-UI components and hooks. */ -export type ShopifyContextValue = { +export type ShopifyContextProps = { /** The globally-unique identifier for the Shop */ storefrontId?: string; /** The host name of the domain (eg: `{shop}.myshopify.com`). If a URL with a scheme (for example `https://`) is passed in, then the scheme is removed. */ @@ -92,3 +112,37 @@ export type ShopifyContextValue = { */ locale?: string; }; + +export type ShopifyContextValue = ShopifyContextProps & { + /** + * Creates the fully-qualified URL to your store's GraphQL endpoint. + * + * By default, it will use the config you passed in when creating ``. However, you can override the following settings on each invocation of `getStorefrontApiUrl({...})`: + * + * - `storeDomain` + * - `storefrontApiVersion` + */ + getStorefrontApiUrl: (props?: { + /** The host name of the domain (eg: `{shop}.myshopify.com`). */ + storeDomain?: string; + /** The Storefront API version. This should almost always be the same as the version Hydrogen-UI was built for. Learn more about Shopify [API versioning](https://shopify.dev/api/usage/versioning) for more details. */ + storefrontApiVersion?: string; + }) => string; + /** + * Returns an object that contains headers that are needed for each query to Storefront API GraphQL endpoint. This uses the public Storefront API token. + * + * By default, it will use the config you passed in when creating ``. However, you can override the following settings on each invocation of `getPublicTokenHeaders({...})`: + * + * - `contentType` + * - `storefrontToken` + * + */ + getPublicTokenHeaders: (props: { + /** + * Customizes which `"content-type"` header is added when using `getPrivateTokenHeaders()` and `getPublicTokenHeaders()`. When fetching with a `JSON.stringify()`-ed `body`, use `"json"`. When fetching with a `body` that is a plain string, use `"graphql"`. Defaults to `"json"` + */ + contentType: 'json' | 'graphql'; + /** The Storefront API access token. Refer to the [authentication](https://shopify.dev/api/storefront#authentication) documentation for more details. */ + storefrontToken?: string; + }) => Record; +}; diff --git a/packages/react/src/cart-constants.ts b/packages/react/src/cart-constants.ts index d9a26cad..0d02d749 100644 --- a/packages/react/src/cart-constants.ts +++ b/packages/react/src/cart-constants.ts @@ -2,8 +2,6 @@ export const CART_ID_STORAGE_KEY = 'shopifyCartId'; export const CART_COOKIE_TTL_DAYS = 14; // Needed for cart analytics within Shopify -export const STOREFRONT_API_PUBLIC_TOKEN_HEADER = - 'X-Shopify-Storefront-Access-Token'; export const SHOPIFY_STOREFRONT_ID_HEADER = 'Shopify-Storefront-Id'; export const SHOPIFY_STOREFRONT_Y_HEADER = 'Shopify-Storefront-Y'; export const SHOPIFY_STOREFRONT_S_HEADER = 'Shopify-Storefront-S'; diff --git a/packages/react/src/cart-hooks.tsx b/packages/react/src/cart-hooks.tsx index 0d2f956d..43022ebd 100644 --- a/packages/react/src/cart-hooks.tsx +++ b/packages/react/src/cart-hooks.tsx @@ -6,7 +6,6 @@ import {CartCreate, defaultCartFragment} from './cart-queries.js'; import {Cart} from './cart-types.js'; import { SHOPIFY_STOREFRONT_ID_HEADER, - STOREFRONT_API_PUBLIC_TOKEN_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, SHOPIFY_STOREFRONT_S_HEADER, SHOPIFY_Y, @@ -16,8 +15,7 @@ import {parse} from 'worktop/cookie'; import type {StorefrontApiResponseOkPartial} from './storefront-api-response.types.js'; export function useCartFetch() { - const {storeDomain, storefrontApiVersion, storefrontToken, storefrontId} = - useShop(); + const {storefrontId, getPublicTokenHeaders, getStorefrontApiUrl} = useShop(); return useCallback( ({ @@ -27,12 +25,7 @@ export function useCartFetch() { query: string; variables: Record; }): Promise> => { - const headers: Record = { - 'Content-Type': 'application/json', - 'X-SDK-Variant': 'hydrogen', - 'X-SDK-Version': storefrontApiVersion, - [STOREFRONT_API_PUBLIC_TOKEN_HEADER]: storefrontToken, - }; + const headers = getPublicTokenHeaders({contentType: 'json'}); if (storefrontId) { headers[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId; @@ -45,17 +38,14 @@ export function useCartFetch() { headers[SHOPIFY_STOREFRONT_S_HEADER] = cookieData[SHOPIFY_S]; } - return fetch( - `https://${storeDomain}/api/${storefrontApiVersion}/graphql.json`, - { - method: 'POST', - headers, - body: JSON.stringify({ - query: query.toString(), - variables, - }), - } - ) + return fetch(getStorefrontApiUrl(), { + method: 'POST', + headers, + body: JSON.stringify({ + query: query.toString(), + variables, + }), + }) .then((res) => res.json()) .catch((error) => { return { @@ -64,7 +54,7 @@ export function useCartFetch() { }; }); }, - [storeDomain, storefrontApiVersion, storefrontToken, storefrontId] + [getPublicTokenHeaders, storefrontId, getStorefrontApiUrl] ); } diff --git a/packages/react/src/storefront-client.ts b/packages/react/src/storefront-client.ts index 9fc2b380..edcffa63 100644 --- a/packages/react/src/storefront-client.ts +++ b/packages/react/src/storefront-client.ts @@ -78,24 +78,34 @@ export function createStorefrontClient({ ); } - const finalContentType = overrideProps?.contentType ?? contentType; + const finalContentType = + overrideProps?.contentType ?? contentType ?? 'json'; - return { - // default to json - 'content-type': - finalContentType === 'graphql' - ? 'application/graphql' - : 'application/json', - 'X-SDK-Variant': 'hydrogen-ui', - 'X-SDK-Variant-Source': 'react', - 'X-SDK-Version': storefrontApiVersion, - 'X-Shopify-Storefront-Access-Token': - overrideProps?.publicStorefrontToken ?? publicStorefrontToken ?? '', - }; + return getPublicTokenHeadersRaw( + finalContentType, + storefrontApiVersion, + overrideProps?.publicStorefrontToken ?? publicStorefrontToken ?? '' + ); }, }; } +export function getPublicTokenHeadersRaw( + contentType: 'graphql' | 'json', + storefrontApiVersion: string, + accessToken: string +) { + return { + // default to json + 'content-type': + contentType === 'graphql' ? 'application/graphql' : 'application/json', + 'X-SDK-Variant': 'hydrogen-ui', + 'X-SDK-Variant-Source': 'react', + 'X-SDK-Version': storefrontApiVersion, + 'X-Shopify-Storefront-Access-Token': accessToken, + }; +} + type StorefrontClientProps = { /** The host name of the domain (eg: `{shop}.myshopify.com`). */ storeDomain: string; @@ -152,9 +162,9 @@ type StorefrontClientReturn = { } ) => Record; /** - * Returns an object that contains headers that are needed for each query to Storefront API GraphQL endpoint. This method uses the private Server-to-Server token which reduces the chance of throttling but must not be exposed to clients. Server-side calls should prefer using this over `getPublicTokenHeaders()`. + * Returns an object that contains headers that are needed for each query to Storefront API GraphQL endpoint. This method uses the public token which increases the chance of throttling but also can be exposed to clients. Server-side calls should prefer using `getPublicTokenHeaders()`. * - * By default, it will use the config you passed in when calling `createStorefrontClient()`. However, you can override the following settings on each invocation of `getPrivateTokenHeaders({...})`: + * By default, it will use the config you passed in when calling `createStorefrontClient()`. However, you can override the following settings on each invocation of `getPublicTokenHeaders({...})`: * * - `contentType` * - `publicStorefrontToken`