diff --git a/.changeset/mighty-buses-travel.md b/.changeset/mighty-buses-travel.md new file mode 100644 index 000000000..bad28f375 --- /dev/null +++ b/.changeset/mighty-buses-travel.md @@ -0,0 +1,5 @@ +--- +'apollo-angular': minor +--- + +Support HttpContext in HttpLink option and operation context diff --git a/packages/apollo-angular/http/src/http-batch-link.ts b/packages/apollo-angular/http/src/http-batch-link.ts index be63af9dc..1400399a6 100644 --- a/packages/apollo-angular/http/src/http-batch-link.ts +++ b/packages/apollo-angular/http/src/http-batch-link.ts @@ -1,12 +1,18 @@ import { print } from 'graphql'; import { Observable } from 'rxjs'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ApolloLink } from '@apollo/client'; import { BatchLink } from '@apollo/client/link/batch'; import type { HttpLink } from './http-link'; import { Body, Context, OperationPrinter, Request } from './types'; -import { createHeadersWithClientAwareness, fetch, mergeHeaders, prioritize } from './utils'; +import { + createHeadersWithClientAwareness, + fetch, + mergeHeaders, + mergeHttpContext, + prioritize, +} from './utils'; export declare namespace HttpBatchLink { export type Options = { @@ -61,6 +67,7 @@ export class HttpBatchLinkHandler extends ApolloLink { return new Observable((observer: any) => { const body = this.createBody(operations); const headers = this.createHeaders(operations); + const context = this.createHttpContext(operations); const { method, uri, withCredentials } = this.createOptions(operations); if (typeof uri === 'function') { @@ -74,6 +81,7 @@ export class HttpBatchLinkHandler extends ApolloLink { options: { withCredentials, headers, + context, }, }; @@ -162,6 +170,16 @@ export class HttpBatchLinkHandler extends ApolloLink { ); } + private createHttpContext(operations: ApolloLink.Operation[]): HttpContext { + return operations.reduce( + (context: HttpContext, operation: ApolloLink.Operation) => { + const { httpContext } = operation.getContext(); + return httpContext ? mergeHttpContext(httpContext, context) : context; + }, + mergeHttpContext(this.options.httpContext, new HttpContext()), + ); + } + private createBatchKey(operation: ApolloLink.Operation): string { const context: Context & { skipBatching?: boolean } = operation.getContext(); diff --git a/packages/apollo-angular/http/src/http-link.ts b/packages/apollo-angular/http/src/http-link.ts index 4528589a5..e4bf84a2b 100644 --- a/packages/apollo-angular/http/src/http-link.ts +++ b/packages/apollo-angular/http/src/http-link.ts @@ -1,6 +1,6 @@ import { print } from 'graphql'; import { Observable } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpContext } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ApolloLink } from '@apollo/client'; import { pick } from './http-batch-link'; @@ -13,7 +13,7 @@ import { OperationPrinter, Request, } from './types'; -import { createHeadersWithClientAwareness, fetch, mergeHeaders } from './utils'; +import { createHeadersWithClientAwareness, fetch, mergeHeaders, mergeHttpContext } from './utils'; export declare namespace HttpLink { export interface Options extends FetchOptions, HttpRequestOptions { @@ -49,6 +49,10 @@ export class HttpLinkHandler extends ApolloLink { const withCredentials = pick(context, this.options, 'withCredentials'); const useMultipart = pick(context, this.options, 'useMultipart'); const useGETForQueries = this.options.useGETForQueries === true; + const httpContext = mergeHttpContext( + context.httpContext, + mergeHttpContext(this.options.httpContext, new HttpContext()), + ); const isQuery = operation.query.definitions.some( def => def.kind === 'OperationDefinition' && def.operation === 'query', @@ -69,6 +73,7 @@ export class HttpLinkHandler extends ApolloLink { withCredentials, useMultipart, headers: this.options.headers, + context: httpContext, }, }; diff --git a/packages/apollo-angular/http/src/types.ts b/packages/apollo-angular/http/src/types.ts index b6802d3cc..e55b2582e 100644 --- a/packages/apollo-angular/http/src/types.ts +++ b/packages/apollo-angular/http/src/types.ts @@ -1,5 +1,5 @@ import { DocumentNode } from 'graphql'; -import { HttpHeaders } from '@angular/common/http'; +import { HttpContext, HttpHeaders } from '@angular/common/http'; import { ApolloLink } from '@apollo/client'; declare module '@apollo/client' { @@ -10,6 +10,11 @@ export type HttpRequestOptions = { headers?: HttpHeaders; withCredentials?: boolean; useMultipart?: boolean; + httpContext?: HttpContext; +}; + +export type RequestOptions = Omit & { + context?: HttpContext; }; export type URIFunction = (operation: ApolloLink.Operation) => string; @@ -36,7 +41,7 @@ export type Request = { method: string; url: string; body: Body | Body[]; - options: HttpRequestOptions; + options: RequestOptions; }; export type ExtractedFiles = { diff --git a/packages/apollo-angular/http/src/utils.ts b/packages/apollo-angular/http/src/utils.ts index f8d8cb03f..47d93117c 100644 --- a/packages/apollo-angular/http/src/utils.ts +++ b/packages/apollo-angular/http/src/utils.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs'; -import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpContext, HttpHeaders, HttpResponse } from '@angular/common/http'; import { Body, ExtractedFiles, ExtractFiles, Request } from './types'; export const fetch = ( @@ -121,6 +121,20 @@ export const mergeHeaders = ( return destination || source; }; +export const mergeHttpContext = ( + source: HttpContext | undefined, + destination: HttpContext, +): HttpContext => { + if (source && destination) { + return [...source.keys()].reduce( + (context, name) => context.set(name, source.get(name)), + destination, + ); + } + + return destination || source; +}; + export function prioritize( ...values: [NonNullable, ...T[]] | [...T[], NonNullable] ): NonNullable { diff --git a/packages/apollo-angular/http/tests/http-batch-link.spec.ts b/packages/apollo-angular/http/tests/http-batch-link.spec.ts index be91a77d1..5cdd3af93 100644 --- a/packages/apollo-angular/http/tests/http-batch-link.spec.ts +++ b/packages/apollo-angular/http/tests/http-batch-link.spec.ts @@ -1,5 +1,11 @@ -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { HttpHeaders, provideHttpClient } from '@angular/common/http'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + HttpClient, + HttpContext, + HttpContextToken, + HttpHeaders, + provideHttpClient, +} from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { ApolloLink, gql } from '@apollo/client'; @@ -759,4 +765,57 @@ describe('HttpBatchLink', () => { done(); }, 50); })); + + test('should merge httpContext from options and batch context and pass it on to HttpClient', () => + new Promise(done => { + const requestSpy = vi.spyOn(TestBed.inject(HttpClient), 'request'); + const contextToken1 = new HttpContextToken(() => ''); + const contextToken2 = new HttpContextToken(() => ''); + const contextToken3 = new HttpContextToken(() => ''); + const link = httpLink.create({ + uri: 'graphql', + httpContext: new HttpContext().set(contextToken1, 'options'), + batchKey: () => 'batchKey', + }); + + const op1 = { + query: gql` + query heroes { + heroes { + name + } + } + `, + context: { + httpContext: new HttpContext().set(contextToken2, 'foo'), + }, + }; + + const op2 = { + query: gql` + query heroes { + heroes { + name + } + } + `, + context: { + httpContext: new HttpContext().set(contextToken3, 'bar'), + }, + }; + + execute(link, op1).subscribe(noop); + execute(link, op2).subscribe(noop); + + setTimeout(() => { + httpBackend.match(() => { + const callOptions = requestSpy.mock.calls[0][2]; + expect(callOptions?.context?.get(contextToken1)).toBe('options'); + expect(callOptions?.context?.get(contextToken2)).toBe('foo'); + expect(callOptions?.context?.get(contextToken3)).toBe('bar'); + done(); + return true; + }); + }, 50); + })); }); diff --git a/packages/apollo-angular/http/tests/http-link.spec.ts b/packages/apollo-angular/http/tests/http-link.spec.ts index 36516b20e..aa778ab10 100644 --- a/packages/apollo-angular/http/tests/http-link.spec.ts +++ b/packages/apollo-angular/http/tests/http-link.spec.ts @@ -1,7 +1,13 @@ import { print, stripIgnoredCharacters } from 'graphql'; import { map, mergeMap } from 'rxjs/operators'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { HttpHeaders, provideHttpClient } from '@angular/common/http'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + HttpClient, + HttpContext, + HttpContextToken, + HttpHeaders, + provideHttpClient, +} from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { ApolloLink, gql, InMemoryCache } from '@apollo/client'; @@ -750,4 +756,39 @@ describe('HttpLink', () => { expect(httpBackend.expectOne('graphql').cancelled).toBe(true); }); + + test('should merge httpContext from options and query context and pass it on to HttpClient', () => + new Promise(done => { + const requestSpy = vi.spyOn(TestBed.inject(HttpClient), 'request'); + const optionsToken = new HttpContextToken(() => ''); + const queryToken = new HttpContextToken(() => ''); + + const optionsContext = new HttpContext().set(optionsToken, 'foo'); + const queryContext = new HttpContext().set(queryToken, 'bar'); + + const link = httpLink.create({ uri: 'graphql', httpContext: optionsContext }); + const op = { + query: gql` + query heroes { + heroes { + name + } + } + `, + context: { + httpContext: queryContext, + }, + }; + + execute(link, op).subscribe(() => { + const callOptions = requestSpy.mock.calls[0][2]; + expect(callOptions?.context?.get(optionsToken)).toBe('foo'); + expect(callOptions?.context?.get(queryToken)).toBe('bar'); + expect(optionsContext.get(queryToken)).toBe(''); + expect(queryContext.get(optionsToken)).toBe(''); + done(); + }); + + httpBackend.expectOne('graphql').flush({ data: {} }); + })); });