Skip to content

Commit d416ba8

Browse files
committed
- Enable custom fetcher impl upon openapi spec load.
1 parent 70ebca9 commit d416ba8

2 files changed

Lines changed: 66 additions & 7 deletions

File tree

packages/server/src/graphql-loader.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ export interface LoadGraphQLOptions {
204204
auth?: AuthConfig;
205205
depthLimit?: number;
206206
queryDepthLimit?: number;
207+
208+
/**
209+
* Custom fetch function for full control over the HTTP transport layer.
210+
* Use this when the target API requires custom TLS certificates (mTLS, custom CA),
211+
* a proxy, or any other transport-level configuration that headers alone cannot provide.
212+
*
213+
* Applied to both schema loading and runtime GraphQL requests.
214+
*
215+
* @example
216+
* ```typescript
217+
* import { Agent } from 'undici';
218+
*
219+
* const mtlsAgent = new Agent({
220+
* connect: { ca: fs.readFileSync('/certs/ca.pem') },
221+
* });
222+
*
223+
* await server.loadGraphQL('https://api.example.com/graphql', {
224+
* fetcher: (url, init) => fetch(url, { ...init, dispatcher: mtlsAgent }),
225+
* });
226+
* ```
227+
*/
228+
fetcher?: (url: string, init: RequestInit) => Promise<Response>;
207229
}
208230

209231
/**
@@ -244,10 +266,11 @@ export async function loadGraphQL(
244266
async function loadSchema(source: string, options: LoadGraphQLOptions): Promise<GraphQLSchema> {
245267
// Check if source is a URL
246268
if (source.startsWith('http://') || source.startsWith('https://')) {
269+
const fetchFn = options.fetcher || fetch;
247270
// Resolve headers for schema loading
248271
const headers = await resolveHeaders(options);
249272

250-
const response = await fetch(source, {
273+
const response = await fetchFn(source, {
251274
method: 'POST',
252275
headers: {
253276
'Content-Type': 'application/json',
@@ -258,7 +281,8 @@ async function loadSchema(source: string, options: LoadGraphQLOptions): Promise<
258281

259282
if (!response.ok) {
260283
// If POST with introspection fails, try GET assuming it might return SDL or JSON
261-
const getResponse = await fetch(source, {
284+
const getResponse = await fetchFn(source, {
285+
method: 'GET',
262286
headers,
263287
});
264288
if (getResponse.ok) {
@@ -352,7 +376,8 @@ function convertFieldToFunction(
352376
// Pass execution context (including requestContext) to resolveHeaders
353377
const headers = await resolveHeaders(options, paramsObj, context);
354378

355-
const response = await fetch(url, {
379+
const fetchFn = options.fetcher || fetch;
380+
const response = await fetchFn(url, {
356381
method: 'POST',
357382
headers: {
358383
'Content-Type': 'application/json',

packages/server/src/openapi-loader.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,35 @@ export interface LoadOpenAPIOptions {
210210
headers?: Record<string, string>;
211211
body?: string | undefined;
212212
};
213+
214+
/**
215+
* Custom fetch function for full control over the HTTP transport layer.
216+
* Use this when the target API requires custom TLS certificates (mTLS, custom CA),
217+
* a proxy, or any other transport-level configuration that headers alone cannot provide.
218+
*
219+
* The function receives the URL and standard RequestInit on every request,
220+
* making it inherently dynamic — you can route to different agents based on the URL.
221+
*
222+
* Applied to both spec loading and runtime API calls.
223+
*
224+
* @example
225+
* ```typescript
226+
* import { Agent } from 'undici';
227+
*
228+
* const mtlsAgent = new Agent({
229+
* connect: {
230+
* ca: fs.readFileSync('/certs/ca.pem'),
231+
* cert: fs.readFileSync('/certs/client.pem'),
232+
* key: fs.readFileSync('/certs/client-key.pem'),
233+
* },
234+
* });
235+
*
236+
* await server.loadOpenAPI('https://partner-api.example.com/spec.json', {
237+
* fetcher: (url, init) => fetch(url, { ...init, dispatcher: mtlsAgent }),
238+
* });
239+
* ```
240+
*/
241+
fetcher?: (url: string, init: RequestInit) => Promise<Response>;
213242
}
214243

215244
/**
@@ -233,7 +262,7 @@ export async function loadOpenAPI(
233262
source: string,
234263
options: LoadOpenAPIOptions = {}
235264
): Promise<APIGroupConfig> {
236-
const spec = await loadSpec(source);
265+
const spec = await loadSpec(source, options.fetcher);
237266

238267
const name = options.name || spec.info.title.toLowerCase().replace(/\s+/g, '-');
239268

@@ -297,12 +326,16 @@ export async function loadOpenAPI(
297326
/**
298327
* Load OpenAPI spec from file or URL
299328
*/
300-
async function loadSpec(source: string): Promise<APISpec> {
329+
async function loadSpec(
330+
source: string,
331+
fetcher?: (url: string, init: RequestInit) => Promise<Response>
332+
): Promise<APISpec> {
301333
let content: string;
302334
let isYaml = false;
303335

304336
if (source.startsWith('http://') || source.startsWith('https://')) {
305-
const response = await fetch(source);
337+
const fetchFn = fetcher || fetch;
338+
const response = await fetchFn(source, { method: 'GET' });
306339
if (!response.ok) {
307340
throw new Error(`Failed to load OpenAPI spec from ${source}: ${response.statusText}`);
308341
}
@@ -625,7 +658,8 @@ function convertOperation(
625658
if (transformed.body !== undefined) finalBody = transformed.body;
626659
}
627660

628-
const response = await fetch(finalUrl, {
661+
const fetchFn = options.fetcher || fetch;
662+
const response = await fetchFn(finalUrl, {
629663
method: finalMethod,
630664
headers: finalHeaders,
631665
body: finalBody,

0 commit comments

Comments
 (0)