Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Customer Account API in GraphiQL #1693

Merged
merged 12 commits into from
Jan 29, 2024
10 changes: 9 additions & 1 deletion packages/cli/src/lib/graphiql-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ export function getGraphiQLUrl({
graphql,
}: {
host?: string;
graphql?: {query: string; variables: string | Record<string, any>};
graphql?: {
query: string;
variables: string | Record<string, any>;
schema?: string;
};
}) {
let url = `${host.endsWith('/') ? host.slice(0, -1) : host}/graphiql`;

Expand All @@ -14,6 +18,10 @@ export function getGraphiQLUrl({
url += `?query=${encodeURIComponent(query)}${
variables ? `&variables=${encodeURIComponent(variables)}` : ''
}`;

if (graphql.schema) {
url += `&schema=${graphql.schema}`;
}
}

return url;
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/lib/mini-oxygen/assets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {createRequire} from 'node:module';
import {
createServer,
type IncomingMessage,
Expand Down Expand Up @@ -39,7 +40,15 @@ export function createAssetsServer(buildPathClient: string) {
: pathname;

if (isValidAssetPath) {
const filePath = path.join(buildPathClient, relativeAssetPath);
let filePath = path.join(buildPathClient, relativeAssetPath);

// Request coming from /graphiql
if (relativeAssetPath === '/graphiql/customer-account.schema.json') {
const require = createRequire(import.meta.url);
filePath = require.resolve(
'@shopify/hydrogen/customer-account.schema.json',
);
}

// Ignore errors and just return 404
const file = await fs.open(filePath).catch(() => {});
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/lib/request-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ async function getRequestInfo(request: RequestKind) {
purpose: data.purpose === 'prefetch' ? '(prefetch)' : '',
cacheStatus: data.cacheStatus ?? '',
graphql: data.graphql
? (JSON.parse(data.graphql) as {query: string; variables: object})
? (JSON.parse(data.graphql) as {
query: string;
variables: object;
schema?: string;
})
: null,
};
}
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/virtual-routes/components/RequestDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ export function RequestDetails({
<div className="grid-layout">
<DetailsRow rowName="Name" value={requestInfo.displayName} />
<DetailsRow rowName="Request URL" value={requestInfo.url} />
<DetailsRow
rowName="Status"
value={`${requestInfo.responseInit?.status} ${requestInfo.responseInit?.statusText}`}
/>
{requestInfo.responseInit ? (
<DetailsRow
rowName="Status"
value={`${requestInfo.responseInit?.status} ${requestInfo.responseInit?.statusText}`}
/>
) : null}
<DetailsRow
rowName="GraphiQL"
value={requestInfo.graphiqlLink}
Expand Down
49 changes: 37 additions & 12 deletions packages/hydrogen/src/customer/customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,42 @@ export function createCustomerAccountClient({
url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin;
const redirectUri = authUrl.startsWith('/') ? origin + authUrl : authUrl;

const customerAccountApiUrl = `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a bunch of similar changes in #1684


const locks: Locks = {};

type LogSubrequestOptions = {
startTime: number;
url?: string;
query?: string;
variables?: Record<string, any> | null;
stackInfo?: StackInfo;
};

const logSubRequestEvent =
process.env.NODE_ENV === 'development'
? (query: string, startTime: number, stackInfo?: StackInfo) => {
? ({
url,
query = '',
variables,
startTime,
stackInfo,
}: LogSubrequestOptions) => {
const shopifyDevUrl = 'https://shopify.dev/';
const cacheKey =
url ||
/((query|mutation) [^\s\(]+)/g.exec(query)?.[0] ||
query.substring(0, 10);

globalThis.__H2O_LOG_EVENT?.({
eventType: 'subrequest',
url: `https://shopify.dev/?${hashKey([
`Customer Account `,
/((query|mutation) [^\s\(]+)/g.exec(query)?.[0] ||
query.substring(0, 10),
])}`,
url: `${shopifyDevUrl}?${hashKey([`Customer Account `, cacheKey])}`,
startTime,
waitUntil,
stackInfo,
graphql:
query &&
JSON.stringify({query, variables, schema: 'customer-account'}),
...getDebugHeaders(request),
});
}
Expand All @@ -128,11 +149,10 @@ export function createCustomerAccountClient({
// Get stack trace before losing it with any async operation.
// Since this is an internal function that is always called from
// the public query/mutate wrappers, add 1 to the stack offset.
const stackInfo = getCallerStackLine?.(1);
const stackInfo = getCallerStackLine?.();

const startTime = new Date().getTime();
const url = `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`;
const response = await fetch(url, {
const response = await fetch(customerAccountApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -147,12 +167,12 @@ export function createCustomerAccountClient({
}),
});

logSubRequestEvent?.(query, startTime, stackInfo);
logSubRequestEvent?.({query, variables, startTime, stackInfo});

const body = await response.text();

const errorOptions: GraphQLErrorOptions<T> = {
url,
url: customerAccountApiUrl,
response,
type,
query,
Expand Down Expand Up @@ -211,7 +231,11 @@ export function createCustomerAccountClient({
origin,
});

logSubRequestEvent?.(' check expires', startTime, stackInfo);
logSubRequestEvent?.({
url: ' check expires',
startTime,
stackInfo,
});
} catch {
return false;
}
Expand Down Expand Up @@ -292,6 +316,7 @@ export function createCustomerAccountClient({
isLoggedIn,
handleAuthStatus,
getAccessToken,
getApiUrl: () => customerAccountApiUrl,
mutate(mutation, options?) {
mutation = minifyQuery(mutation);
assertMutation(mutation, 'customer.mutate');
Expand Down
2 changes: 2 additions & 0 deletions packages/hydrogen/src/customer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export type CustomerClient = {
handleAuthStatus: () => void | DataFunctionValue;
/** Returns CustomerAccessToken if the customer is logged in. It also run a expiry check and does a token refresh if needed. */
getAccessToken: () => Promise<string | undefined>;
/** Creates the fully-qualified URL to your store's GraphQL endpoint.*/
getApiUrl: () => string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reminder to add this to the Doc type below as well

/** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.*/
logout: () => Promise<Response>;
/** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */
Expand Down
137 changes: 123 additions & 14 deletions packages/hydrogen/src/routing/graphiql.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,60 @@
import type {LoaderFunctionArgs} from '@remix-run/server-runtime';
import type {Storefront} from '../storefront';

type GraphiQLLoader = (args: LoaderFunctionArgs) => Promise<Response>;

export const graphiqlLoader: GraphiQLLoader = async function graphiqlLoader({
context,
request,
context: {storefront, customerAccount},
}: LoaderFunctionArgs) {
const storefront = context?.storefront as Storefront | undefined;
const url = new URL(request.url);

if (!storefront) {
throw new Error(
`GraphiQL: Hydrogen's storefront client must be injected in the loader context.`,
);
}

const url = storefront.getApiUrl();
const accessToken =
storefront.getPublicTokenHeaders()['X-Shopify-Storefront-Access-Token'];
const schemas: {
[key: string]: {
name: string;
value?: object;
accessToken?: string;
authHeader: string;
apiUrl: string;
};
} = {};

if (storefront) {
const authHeader = 'X-Shopify-Storefront-Access-Token';
schemas.storefront = {
name: 'Storefront API',
authHeader,
accessToken: storefront.getPublicTokenHeaders()[authHeader],
apiUrl: storefront.getApiUrl(),
};
}

if (customerAccount) {
// CustomerAccount API does not support introspection to the same URL.
// Read it from a file using the asset server:
Comment on lines +40 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@s-lee-kwong Can we add introspection to the graphql end point?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is something we can look at but right now our only introspection point is something like

https://app.myshopify.com/services/graphql/introspection/customer?api_client_api_key=159a99b8a7289a72f68603f2f4de40ac&api_version=2024-01

but that api_key is from an app so I wouldn't use what I pasted exactly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GraphiQL can only work with 1 url for both introspection and queries. So if you want to add support for GraphiQL in CAAPI, the URL for queries should also provide introspection 🙏

Here we had to work around this issue by downloading the schema in a different way and providing it to GarphiQL in JSON format without doing introspection.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add this to the on going Friction Doc for the future.

Having the introspection and queries be at the same URL is a fairly standard way of implementation for GraphQL API. A lot of the tools work assuming this convention and it would be really good to have.

const customerAccountSchema = await (
await fetch(url.origin + '/graphiql/customer-account.schema.json')
).json();

// @ts-ignore This is recognized in editor but not at build time
const accessToken = await customerAccount.getAccessToken();

if (customerAccountSchema) {
schemas['customer-account'] = {
name: 'Customer Account API',
value: customerAccountSchema,
authHeader: 'Authorization',
accessToken,
// @ts-ignore This is recognized in editor but not at build time
apiUrl: customerAccount.getApiUrl(),
};
}
}

// GraphiQL icon from their GitHub repo
const favicon = `https://avatars.githubusercontent.com/u/12972006?s=48&v=4`;
Expand Down Expand Up @@ -87,6 +126,8 @@ export const graphiqlLoader: GraphiQLLoader = async function graphiqlLoader({

<script>
const windowUrl = new URL(document.URL);
const startingSchemaKey =
windowUrl.searchParams.get('schema') || 'storefront';

let query = '{ shop { name } }';
if (windowUrl.searchParams.has('query')) {
Expand All @@ -98,6 +139,10 @@ export const graphiqlLoader: GraphiQLLoader = async function graphiqlLoader({
// Prettify query
query = GraphiQL.GraphQL.print(GraphiQL.GraphQL.parse(query));

if (startingSchemaKey !== 'storefront') {
query += ' #schema:' + startingSchemaKey;
}

let variables;
if (windowUrl.searchParams.has('variables')) {
variables = decodeURIComponent(
Expand All @@ -110,23 +155,87 @@ export const graphiqlLoader: GraphiQLLoader = async function graphiqlLoader({
variables = JSON.stringify(JSON.parse(variables), null, 2);
}

const schemas = ${JSON.stringify(schemas)};
let lastActiveTabIndex = -1;

const root = ReactDOM.createRoot(
document.getElementById('graphiql'),
);
root.render(
React.createElement(GraphiQL, {

root.render(React.createElement(RootWrapper));

function RootWrapper() {
const [activeSchema, setActiveSchema] =
React.useState(startingSchemaKey);

const schema = schemas[activeSchema];
if (!schema) {
throw new Error('No schema found for ' + activeSchema);
}

const keys = Object.keys(schemas);

return React.createElement(GraphiQL, {
fetcher: GraphiQL.createFetcher({
url: '${url}',
headers: {
'X-Shopify-Storefront-Access-Token': '${accessToken}',
},
url: schema.apiUrl,
headers: {[schema.authHeader]: schema.accessToken},
}),
defaultEditorToolsVisibility: true,
query,
variables,
schema: schema.value,
plugins: [GraphiQLPluginExplorer.explorerPlugin()],
}),
);
onTabChange: ({tabs, activeTabIndex}) => {
if (activeTabIndex === lastActiveTabIndex) return;

lastActiveTabIndex = activeTabIndex;

const activeTab = tabs[activeTabIndex];
if (activeTab) {
const nextSchema =
activeTab.query.match(/#schema:([a-z-]+)/m)?.[1] ||
'storefront';

if (nextSchema !== activeSchema) {
setActiveSchema(nextSchema);
}
}
},
children: [
// React.createElement(GraphiQL.Toolbar, {}),
React.createElement(GraphiQL.Logo, {
key: 'Logo replacement',
children: [
React.createElement('div', {
key: 'Logo wrapper',
style: {display: 'flex', alignItems: 'center'},
children: [
React.createElement('div', {
key: 'api',
className: 'graphiql-logo',
style: {
paddingRight: 0,
whiteSpace: 'nowrap',
cursor: 'pointer',
},
onClick: () => {
clicked = true;
const activeKey = keys.indexOf(activeSchema);
const nextKey =
keys[(activeKey + 1) % keys.length];

setActiveSchema(nextKey);
},
children: [schema.name],
}),
React.createElement(GraphiQL.Logo, {key: 'logo'}),
],
}),
],
}),
],
});
}
</script>
</body>
</html>
Expand Down
Loading