Skip to content

Commit 54d7c74

Browse files
committed
feat: CATALYST-1664 add graphql proxy in middleware
1 parent dcad856 commit 54d7c74

File tree

3 files changed

+183
-0
lines changed

3 files changed

+183
-0
lines changed

.changeset/foo-bar-baz.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
import { z } from 'zod';
16+
17+
import { auth } from '~/auth';
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+
const bodySchmea = z.object({
26+
query: z.unknown(),
27+
variables: z.record(z.unknown()).default({}),
28+
});
29+
30+
export const withGraphqlProxy: MiddlewareFactory = (next) => {
31+
return async (request, event) => {
32+
// Only handle /graphql path
33+
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
34+
return next(request, event);
35+
}
36+
37+
const requester = request.headers.get('x-catalyst-graphql-proxy-requester');
38+
39+
// Validate required header
40+
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
41+
return next(request, event);
42+
}
43+
44+
// Only handle POST requests
45+
if (request.method !== 'POST') {
46+
return new NextResponse('Method not allowed', { status: 405 });
47+
}
48+
49+
// Wrap in auth to get customer access token
50+
return auth(async (req) => {
51+
try {
52+
// Parse incoming GraphQL request body
53+
const body: unknown = await req.json();
54+
const { query, variables } = bodySchmea.parse(body);
55+
56+
if (!query) {
57+
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
58+
}
59+
60+
// Get customer access token if authenticated
61+
const customerAccessToken = req.auth?.user?.customerAccessToken;
62+
63+
// Proxy the request using the existing client
64+
const response = await client.fetch({
65+
document: query,
66+
variables,
67+
customerAccessToken,
68+
fetchOptions: {
69+
next: { revalidate: 0 }, // Don't cache, but avoid triggering headers() in beforeRequest
70+
},
71+
});
72+
73+
return NextResponse.json(response);
74+
} catch (error) {
75+
// eslint-disable-next-line no-console
76+
console.error(error);
77+
78+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
79+
}
80+
// @ts-expect-error The return of `auth(() => ...) is overloaded to expect `return next(req, event)` to be the valid middleware type
81+
// however we are returning NextResponse directly here because we want to proxy GraphQL requests. This is fine to ignore.
82+
})(request, event);
83+
};
84+
};
85+
```
86+
87+
### Step 2
88+
89+
Update `core/middleware.ts` to include the new middleware in the composition chain:
90+
91+
```diff
92+
import { composeMiddlewares } from './middlewares/compose-middlewares';
93+
import { withAnalyticsCookies } from './middlewares/with-analytics-cookies';
94+
import { withAuth } from './middlewares/with-auth';
95+
import { withChannelId } from './middlewares/with-channel-id';
96+
+ import { withGraphqlProxy } from './middlewares/with-graphql-proxy';
97+
import { withIntl } from './middlewares/with-intl';
98+
import { withRoutes } from './middlewares/with-routes';
99+
100+
export const middleware = composeMiddlewares(
101+
withAuth,
102+
withIntl,
103+
withAnalyticsCookies,
104+
withChannelId,
105+
+ withGraphqlProxy,
106+
withRoutes,
107+
);
108+
```
109+
110+
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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { NextResponse, URLPattern } from 'next/server';
2+
import { z } from 'zod';
3+
4+
import { auth } from '~/auth';
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+
const bodySchmea = z.object({
13+
query: z.unknown(),
14+
variables: z.record(z.unknown()).default({}),
15+
});
16+
17+
export const withGraphqlProxy: MiddlewareFactory = (next) => {
18+
return async (request, event) => {
19+
// Only handle /graphql path
20+
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
21+
return next(request, event);
22+
}
23+
24+
const requester = request.headers.get('x-catalyst-graphql-proxy-requester');
25+
26+
// Validate required header
27+
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
28+
return next(request, event);
29+
}
30+
31+
// Only handle POST requests
32+
if (request.method !== 'POST') {
33+
return new NextResponse('Method not allowed', { status: 405 });
34+
}
35+
36+
// Wrap in auth to get customer access token
37+
return auth(async (req) => {
38+
try {
39+
// Parse incoming GraphQL request body
40+
const body: unknown = await req.json();
41+
const { query, variables } = bodySchmea.parse(body);
42+
43+
if (!query) {
44+
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
45+
}
46+
47+
// Get customer access token if authenticated
48+
const customerAccessToken = req.auth?.user?.customerAccessToken;
49+
50+
// Proxy the request using the existing client
51+
const response = await client.fetch({
52+
document: query,
53+
variables,
54+
customerAccessToken,
55+
fetchOptions: {
56+
next: { revalidate: 0 }, // Don't cache, but avoid triggering headers() in beforeRequest
57+
},
58+
});
59+
60+
return NextResponse.json(response);
61+
} catch (error) {
62+
// eslint-disable-next-line no-console
63+
console.error(error);
64+
65+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
66+
}
67+
// @ts-expect-error The return of `auth(() => ...) is overloaded to expect `return next(req, event)` to be the valid middleware type
68+
// however we are returning NextResponse directly here because we want to proxy GraphQL requests. This is fine to ignore.
69+
})(request, event);
70+
};
71+
};

0 commit comments

Comments
 (0)