Skip to content

Commit ed14c99

Browse files
committed
feat: CATALYST-1664 add graphql proxy in middleware
1 parent 8096cc5 commit ed14c99

File tree

3 files changed

+189
-0
lines changed

3 files changed

+189
-0
lines changed

.changeset/foo-bar-baz.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
headers: {
70+
Cookie: request.headers.get('cookie') || '',
71+
},
72+
next: { revalidate: 0 }, // Don't cache, but avoid triggering headers() in beforeRequest
73+
},
74+
});
75+
76+
return NextResponse.json(response);
77+
} catch (error) {
78+
// eslint-disable-next-line no-console
79+
console.error(error);
80+
81+
return NextResponse.json(error, { status: 500 });
82+
}
83+
// @ts-expect-error The return of `auth(() => ...) is overloaded to expect `return next(req, event)` to be the valid middleware type
84+
// however we are returning NextResponse directly here because we want to proxy GraphQL requests. This is fine to ignore.
85+
})(request, event);
86+
};
87+
};
88+
```
89+
90+
### Step 2
91+
92+
Update `core/middleware.ts` to include the new middleware in the composition chain:
93+
94+
```diff
95+
import { composeMiddlewares } from './middlewares/compose-middlewares';
96+
import { withAnalyticsCookies } from './middlewares/with-analytics-cookies';
97+
import { withAuth } from './middlewares/with-auth';
98+
import { withChannelId } from './middlewares/with-channel-id';
99+
+ import { withGraphqlProxy } from './middlewares/with-graphql-proxy';
100+
import { withIntl } from './middlewares/with-intl';
101+
import { withRoutes } from './middlewares/with-routes';
102+
103+
export const middleware = composeMiddlewares(
104+
withAuth,
105+
withIntl,
106+
withAnalyticsCookies,
107+
withChannelId,
108+
+ withGraphqlProxy,
109+
withRoutes,
110+
);
111+
```
112+
113+
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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
headers: {
57+
Cookie: request.headers.get('cookie') || '',
58+
},
59+
next: { revalidate: 0 }, // Don't cache, but avoid triggering headers() in beforeRequest
60+
},
61+
});
62+
63+
return NextResponse.json(response);
64+
} catch (error) {
65+
// eslint-disable-next-line no-console
66+
console.error(error);
67+
68+
return NextResponse.json(error, { status: 500 });
69+
}
70+
// @ts-expect-error The return of `auth(() => ...) is overloaded to expect `return next(req, event)` to be the valid middleware type
71+
// however we are returning NextResponse directly here because we want to proxy GraphQL requests. This is fine to ignore.
72+
})(request, event);
73+
};
74+
};

0 commit comments

Comments
 (0)