Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions packages/server/__tests__/openapi-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1394,7 +1394,7 @@ paths:
});
});

describe('headerProvider and contextProvider', () => {
describe('headerProvider', () => {
it('should accept headerProvider option', async () => {
const spec = {
openapi: '3.0.0',
Expand All @@ -1416,10 +1416,48 @@ paths:
const apiGroup = await loadOpenAPI(path, { headerProvider });

expect(apiGroup.functions).toHaveLength(1);
// headerProvider is used at runtime, not at load time
});

it('should accept contextProvider option', async () => {
it('should pass requestContext directly to headerProvider', async () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Test', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/test': {
get: {
operationId: 'test',
responses: { '200': { description: 'OK' } },
},
},
},
};

const path = await writeSpec('header-provider-context.json', spec);
const headerProvider = vi.fn().mockResolvedValue({ Authorization: 'Bearer tok_123' });

const apiGroup = await loadOpenAPI(path, {
headerProvider,
baseURL: 'https://api.example.com',
});

const handler = apiGroup.functions![0].handler!;
const requestContext = { userId: 'user-1', headers: { 'x-tenant': 'acme' } };

try {
await handler({}, { requestContext, emit: () => {} });
} catch {
// fetch will fail, we only care about what headerProvider received
}

expect(headerProvider).toHaveBeenCalledTimes(1);
expect(headerProvider).toHaveBeenCalledWith(
{},
requestContext
);
});

it('should still accept deprecated contextProvider for backward compatibility', async () => {
const spec = {
openapi: '3.0.0',
info: { title: 'Test', version: '1.0.0' },
Expand Down
29 changes: 15 additions & 14 deletions packages/server/src/graphql-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,24 @@ import fs from 'node:fs/promises';
* Dynamic header provider for GraphQL requests.
*
* @param params - The GraphQL query parameters
* @param context - Optional context from contextProvider (e.g., auth tokens)
* @param requestContext - Optional request context from the execution environment (e.g., user info, auth tokens)
*/
export type GraphQLAuthProvider = (
params?: Record<string, any>,
context?: Record<string, any>
requestContext?: Record<string, any>
) => Promise<Record<string, string>> | Record<string, string>;

/**
* Resolve headers for GraphQL requests based on auth options
* Priority: headerProvider > authProvider + auth > static headers
* @param options - Load options including auth config
* @param params - Optional request params passed to headerProvider for dynamic resolution
* @param executionContext - Optional execution context passed from handler (contains requestContext)
* @param requestContext - Optional request context from the execution environment (e.g., user info, auth tokens)
*/
async function resolveHeaders(
options: LoadGraphQLOptions,
params?: Record<string, any>,
executionContext?: Record<string, any>
requestContext?: Record<string, any>
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};

Expand All @@ -65,10 +65,11 @@ async function resolveHeaders(
}

if (options.headerProvider) {
const context = options.contextProvider
? await options.contextProvider(executionContext)
: undefined;
const dynamicHeaders = await options.headerProvider(params, context);
let ctx = requestContext;
if (options.contextProvider) {
ctx = await options.contextProvider(requestContext);
}
const dynamicHeaders = await options.headerProvider(params, ctx);
Object.assign(headers, dynamicHeaders);
}

Expand Down Expand Up @@ -193,10 +194,11 @@ export interface LoadGraphQLOptions {
headers?: Record<string, string>;
/** Auth provider for dynamic credential resolution */
authProvider?: AuthProvider;
/** Dynamic header provider function - called before each request */
/** Dynamic header provider function - called before each request with params and requestContext */
headerProvider?: GraphQLAuthProvider;
/** Context provider - called before each request to get current context (e.g., auth tokens).
* Receives execution context which may contain requestContext from execute() call. */
/**
* @deprecated Use headerProvider instead, which now receives requestContext directly as its second parameter.
*/
contextProvider?: (
executionContext?: Record<string, any>
) => Record<string, any> | Promise<Record<string, any>>;
Expand Down Expand Up @@ -342,7 +344,7 @@ function convertFieldToFunction(
description,
inputSchema,
outputSchema,
handler: async (params: unknown, context?: { metadata?: Record<string, any> }) => {
handler: async (params: unknown, context?: { metadata?: Record<string, any>; requestContext?: Record<string, unknown> }) => {
const paramsObj = (params as Record<string, any>) || {};
const customFields = paramsObj._fields;

Expand Down Expand Up @@ -373,8 +375,7 @@ function convertFieldToFunction(
context.metadata.graphql_variables = variables;
}

// Pass execution context (including requestContext) to resolveHeaders
const headers = await resolveHeaders(options, paramsObj, context);
const headers = await resolveHeaders(options, paramsObj, context?.requestContext);

const fetchFn = options.fetcher || fetch;
const response = await fetchFn(url, {
Expand Down
22 changes: 9 additions & 13 deletions packages/server/src/openapi-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,18 @@ export interface LoadOpenAPIOptions {

/**
* Dynamic header provider for per-request authentication (e.g., per-user OAuth).
* Similar to GraphQL's headerProvider. Called before each API request.
* Called before each API request with the request parameters and the execution's requestContext.
* @param params - The request parameters
* @param context - Optional context from contextProvider
* @param requestContext - Optional request context from the execution environment (e.g., user info, auth tokens)
* @returns Headers to add to the request
*/
headerProvider?: (
params: Record<string, unknown> | undefined,
context?: Record<string, unknown>
requestContext?: Record<string, unknown>
) => Promise<Record<string, string>> | Record<string, string>;

/**
* Context provider to extract context from execution environment.
* Similar to GraphQL's contextProvider. Called once per request.
* @param executionContext - The execution context from ATP
* @returns Context object passed to headerProvider
* @deprecated Use headerProvider instead, which now receives requestContext directly as its second parameter.
*/
contextProvider?: (
executionContext?: Record<string, unknown>
Expand Down Expand Up @@ -515,13 +512,12 @@ function convertOperation(
'Content-Type': 'application/json',
};

let context: Record<string, unknown> | undefined;
if (options.contextProvider) {
context = await options.contextProvider(handlerContext?.requestContext);
}

if (options.headerProvider) {
const dynamicHeaders = await options.headerProvider(input, context);
let requestContext = handlerContext?.requestContext;
if (options.contextProvider) {
requestContext = await options.contextProvider(requestContext);
}
const dynamicHeaders = await options.headerProvider(input, requestContext);
Object.assign(headers, dynamicHeaders);
log.debug('Added headers from headerProvider', { keys: Object.keys(dynamicHeaders) });
}
Expand Down
Loading