Skip to content

Commit c6acb4f

Browse files
committed
feat: CATALYST-1664 add graphql proxy in middleware
1 parent f65d81f commit c6acb4f

File tree

3 files changed

+199
-0
lines changed

3 files changed

+199
-0
lines changed

.changeset/foo-bar-baz.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Add GraphQL proxy middleware to enable client-side GraphQL requests through the storefront. This middleware proxies authenticated GraphQL requests from allowed clients (such as checkout-sdk-js) to the BigCommerce Storefront API, automatically handling customer authentication and channel resolution.
6+
7+
## Migration
8+
9+
### Step 1
10+
11+
Create a new file `core/middlewares/with-graphql-proxy.ts`:
12+
13+
```ts
14+
import { NextResponse, URLPattern } from 'next/server';
15+
16+
import { auth } from '~/auth';
17+
import { getChannelIdFromLocale } from '~/channels.config';
18+
import { client } from '~/client';
19+
20+
import { type MiddlewareFactory } from './compose-middlewares';
21+
22+
const ALLOWED_REQUESTERS = ['checkout-sdk-js'];
23+
const graphqlPathPattern = new URLPattern({ pathname: '/graphql' });
24+
25+
export const withGraphqlProxy: MiddlewareFactory = (next) => {
26+
return async (request, event) => {
27+
// Only handle /graphql path
28+
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
29+
return next(request, event);
30+
}
31+
32+
const requester = request.headers.get('x-catalyst-graphql-proxy-requester');
33+
34+
// Validate required header
35+
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
36+
return next(request, event);
37+
}
38+
39+
// Only handle POST requests
40+
if (request.method !== 'POST') {
41+
return new NextResponse('Method not allowed', { status: 405 });
42+
}
43+
44+
// Wrap in auth to get customer access token
45+
return auth(async (req) => {
46+
try {
47+
// Parse incoming GraphQL request body
48+
const body = await req.json();
49+
const { query, variables } = body;
50+
51+
if (!query) {
52+
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
53+
}
54+
55+
// Resolve channel ID from locale header (set by withChannelId middleware)
56+
const locale = req.headers.get('x-bc-locale') ?? '';
57+
const channelId = getChannelIdFromLocale(locale) ?? undefined;
58+
59+
// Get customer access token if authenticated
60+
const customerAccessToken = req.auth?.user?.customerAccessToken;
61+
62+
// Forward IP address for personalization
63+
const ipAddress = req.headers.get('X-Forwarded-For');
64+
65+
// Proxy the request using the existing client
66+
const response = await client.fetch({
67+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
68+
document: query,
69+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
70+
variables,
71+
customerAccessToken,
72+
channelId,
73+
fetchOptions: {
74+
next: { revalidate: 0 }, // Don't cache, but avoid triggering headers() in beforeRequest
75+
headers: ipAddress
76+
? { 'X-Forwarded-For': ipAddress, 'True-Client-IP': ipAddress }
77+
: undefined,
78+
},
79+
});
80+
81+
return NextResponse.json(response);
82+
} catch (error) {
83+
// eslint-disable-next-line no-console
84+
console.error(error);
85+
86+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
87+
}
88+
// @ts-expect-error The return of `auth(() => ...) is overloaded to expect `return next(req, event)` to be the valid middleware type
89+
// however we are returning NextResponse directly here because we want to proxy GraphQL requests. This is fine to ignore.
90+
})(request, event);
91+
};
92+
};
93+
```
94+
95+
### Step 2
96+
97+
Update `core/middleware.ts` to include the new middleware in the composition chain:
98+
99+
```diff
100+
import { composeMiddlewares } from './middlewares/compose-middlewares';
101+
import { withAnalyticsCookies } from './middlewares/with-analytics-cookies';
102+
import { withAuth } from './middlewares/with-auth';
103+
import { withChannelId } from './middlewares/with-channel-id';
104+
+ import { withGraphqlProxy } from './middlewares/with-graphql-proxy';
105+
import { withIntl } from './middlewares/with-intl';
106+
import { withRoutes } from './middlewares/with-routes';
107+
108+
export const middleware = composeMiddlewares(
109+
withAuth,
110+
withIntl,
111+
withAnalyticsCookies,
112+
withChannelId,
113+
+ withGraphqlProxy,
114+
withRoutes,
115+
);
116+
```
117+
118+
The `withGraphqlProxy` middleware should be placed after `withChannelId` (to access the resolved locale) and before `withRoutes` in the middleware chain.

core/middleware.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { composeMiddlewares } from './middlewares/compose-middlewares';
22
import { withAnalyticsCookies } from './middlewares/with-analytics-cookies';
33
import { withAuth } from './middlewares/with-auth';
44
import { withChannelId } from './middlewares/with-channel-id';
5+
import { withGraphqlProxy } from './middlewares/with-graphql-proxy';
56
import { withIntl } from './middlewares/with-intl';
67
import { withRoutes } from './middlewares/with-routes';
78

@@ -10,6 +11,7 @@ export const middleware = composeMiddlewares(
1011
withIntl,
1112
withAnalyticsCookies,
1213
withChannelId,
14+
withGraphqlProxy,
1315
withRoutes,
1416
);
1517

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { NextResponse, URLPattern } from 'next/server';
2+
3+
import { auth } from '~/auth';
4+
import { getChannelIdFromLocale } from '~/channels.config';
5+
import { client } from '~/client';
6+
7+
import { type MiddlewareFactory } from './compose-middlewares';
8+
9+
const ALLOWED_REQUESTERS = ['checkout-sdk-js'];
10+
const graphqlPathPattern = new URLPattern({ pathname: '/graphql' });
11+
12+
export const withGraphqlProxy: MiddlewareFactory = (next) => {
13+
return async (request, event) => {
14+
// Only handle /graphql path
15+
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
16+
return next(request, event);
17+
}
18+
19+
const requester = request.headers.get('x-catalyst-graphql-proxy-requester');
20+
21+
// Validate required header
22+
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
23+
return next(request, event);
24+
}
25+
26+
// Only handle POST requests
27+
if (request.method !== 'POST') {
28+
return new NextResponse('Method not allowed', { status: 405 });
29+
}
30+
31+
// Wrap in auth to get customer access token
32+
return auth(async (req) => {
33+
try {
34+
// Parse incoming GraphQL request body
35+
const body = await req.json();
36+
const { query, variables } = body;
37+
38+
if (!query) {
39+
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
40+
}
41+
42+
// Resolve channel ID from locale header (set by withChannelId middleware)
43+
const locale = req.headers.get('x-bc-locale') ?? '';
44+
const channelId = getChannelIdFromLocale(locale) ?? undefined;
45+
46+
// Get customer access token if authenticated
47+
const customerAccessToken = req.auth?.user?.customerAccessToken;
48+
49+
// Forward IP address for personalization
50+
const ipAddress = req.headers.get('X-Forwarded-For');
51+
52+
// Proxy the request using the existing client
53+
const response = await client.fetch({
54+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
55+
document: query,
56+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
57+
variables,
58+
customerAccessToken,
59+
channelId,
60+
fetchOptions: {
61+
next: { revalidate: 0 }, // Don't cache, but avoid triggering headers() in beforeRequest
62+
headers: ipAddress
63+
? { 'X-Forwarded-For': ipAddress, 'True-Client-IP': ipAddress }
64+
: undefined,
65+
},
66+
});
67+
68+
return NextResponse.json(response);
69+
} catch (error) {
70+
// eslint-disable-next-line no-console
71+
console.error(error);
72+
73+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
74+
}
75+
// @ts-expect-error The return of `auth(() => ...) is overloaded to expect `return next(req, event)` to be the valid middleware type
76+
// however we are returning NextResponse directly here because we want to proxy GraphQL requests. This is fine to ignore.
77+
})(request, event);
78+
};
79+
};

0 commit comments

Comments
 (0)