From b8276d12c3532f94ee4c52f19d21bc7e2b5ead94 Mon Sep 17 00:00:00 2001 From: "Joseph R. Quinn" <423821+quinnjr@users.noreply.github.com> Date: Sat, 19 Oct 2024 15:38:05 -0400 Subject: [PATCH] Adding customization for "events" processing to Http client --- .changeset/clean-dragons-return.md | 5 + .devcontainer/base.Dockerfile | 6 +- .devcontainer/devcontainer.json | 2 +- package.json | 2 +- .../http/src/http-batch-link.ts | 10 +- packages/apollo-angular/http/src/http-link.ts | 63 ++++- packages/apollo-angular/http/src/types.ts | 18 +- packages/apollo-angular/http/src/utils.ts | 35 ++- .../http/tests/http-link.spec.ts | 259 +++++++++++++++++- packages/apollo-angular/src/subscription.ts | 2 +- 10 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 .changeset/clean-dragons-return.md diff --git a/.changeset/clean-dragons-return.md b/.changeset/clean-dragons-return.md new file mode 100644 index 000000000..7233b4fa8 --- /dev/null +++ b/.changeset/clean-dragons-return.md @@ -0,0 +1,5 @@ +--- +'apollo-angular': minor +--- + +Adding additional configurable support for the underlying Angular Http Client diff --git a/.devcontainer/base.Dockerfile b/.devcontainer/base.Dockerfile index b1c4bcd49..dae29520f 100644 --- a/.devcontainer/base.Dockerfile +++ b/.devcontainer/base.Dockerfile @@ -1,5 +1,5 @@ -# Update the VARIANT arg in devcontainer.json to pick a Node.js version: 14, 12, 10 -ARG VARIANT=14 +# Update the VARIANT arg in devcontainer.json to pick a Node.js version: 14, 12, 10 +ARG VARIANT=18 FROM node:${VARIANT} # Options for setup scripts @@ -10,7 +10,7 @@ ARG USER_UID=1000 ARG USER_GID=$USER_UID ENV NVM_DIR=/usr/local/share/nvm -ENV NVM_SYMLINK_CURRENT=true \ +ENV NVM_SYMLINK_CURRENT=true \ PATH=${NVM_DIR}/current/bin:${PATH} # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 466382121..c0693cf8f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ "build": { "dockerfile": "Dockerfile", // Update 'VARIANT' to pick a Node version: 10, 12, 14 - "args": { "VARIANT": "14" } + "args": { "VARIANT": "18" } }, // Set *default* container specific settings.json values on container create. diff --git a/package.json b/package.json index 7cbac117b..30698db55 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ ] }, "engines": { - "node": ">=16" + "node": ">=18" }, "scripts": { "build": "yarn workspaces run build", diff --git a/packages/apollo-angular/http/src/http-batch-link.ts b/packages/apollo-angular/http/src/http-batch-link.ts index 93a70e434..4de99f6d5 100644 --- a/packages/apollo-angular/http/src/http-batch-link.ts +++ b/packages/apollo-angular/http/src/http-batch-link.ts @@ -1,5 +1,5 @@ import { print } from 'graphql'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ApolloLink, @@ -75,7 +75,13 @@ export class HttpBatchLinkHandler extends ApolloLink { const sub = fetch(req, this.httpClient, () => { throw new Error('File upload is not available when combined with Batching'); }).subscribe({ - next: result => observer.next(result.body), + next: result => { + if (result instanceof HttpResponse) { + observer.next(result.body); + } else { + observer.next(result); + } + }, error: err => observer.error(err), complete: () => observer.complete(), }); diff --git a/packages/apollo-angular/http/src/http-link.ts b/packages/apollo-angular/http/src/http-link.ts index 7d40d281f..f29fbfe35 100644 --- a/packages/apollo-angular/http/src/http-link.ts +++ b/packages/apollo-angular/http/src/http-link.ts @@ -1,14 +1,14 @@ import { print } from 'graphql'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpResponse, HttpEvent, HttpEventType } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ApolloLink, FetchResult, Observable as LinkObservable, - Operation, + Operation } from '@apollo/client/core'; import { pick } from './http-batch-link'; -import { Body, Context, OperationPrinter, Options, Request } from './types'; +import { Body, Context, HttpClientReturn, OperationPrinter, Options, Request } from './types'; import { createHeadersWithClientAwareness, fetch, mergeHeaders } from './utils'; // XXX find a better name for it @@ -57,6 +57,9 @@ export class HttpLinkHandler extends ApolloLink { withCredentials, useMultipart, headers: this.options.headers, + observe: context.observe, + reportProgress: context.reportProgress, + responseType: context.responseType }, }; @@ -73,9 +76,23 @@ export class HttpLinkHandler extends ApolloLink { req.options.headers = mergeHeaders(req.options.headers, headers); const sub = fetch(req, this.httpClient, this.options.extractFiles).subscribe({ - next: response => { + next: (response: HttpClientReturn) => { operation.setContext({ response }); - observer.next(response.body); + + if (context.responseType === 'blob' || + context.responseType === 'arraybuffer' || + context.responseType === 'text') { + observer.next(response); + return; + } + + if (response instanceof HttpResponse) { + observer.next(response.body); + } else if (this.isHttpEvent(response)) { + this.handleHttpEvent(response, observer); + } else { + observer.next(response); + } }, error: err => observer.error(err), complete: () => observer.complete(), @@ -92,6 +109,42 @@ export class HttpLinkHandler extends ApolloLink { public request(op: Operation): LinkObservable | null { return this.requester(op); } + + private isHttpEvent(response: HttpClientReturn): response is HttpEvent { + return typeof response === 'object' && response !== null && 'type' in response; + } + + private handleHttpEvent(event: HttpEvent, observer: any) { + switch (event.type) { + case HttpEventType.Response: + if (event instanceof HttpResponse) { + observer.next(event.body); + } + break; + case HttpEventType.DownloadProgress: + case HttpEventType.UploadProgress: + observer.next({ + data: null, + extensions: { + httpEvent: { + type: event.type, + loaded: 'loaded' in event ? event.loaded : undefined, + total: 'total' in event ? event.total : undefined + } + } + }); + break; + default: + observer.next({ + data: null, + extensions: { + httpEvent: { + type: event.type + } + } + }); + } + } } @Injectable({ diff --git a/packages/apollo-angular/http/src/types.ts b/packages/apollo-angular/http/src/types.ts index 58ddf3612..46dffda6e 100644 --- a/packages/apollo-angular/http/src/types.ts +++ b/packages/apollo-angular/http/src/types.ts @@ -1,13 +1,27 @@ import { DocumentNode } from 'graphql'; -import { HttpHeaders } from '@angular/common/http'; -import { Operation } from '@apollo/client/core'; +import { HttpHeaders, HttpContext, HttpResponse, HttpEvent } from '@angular/common/http'; +import { Operation, FetchResult } from '@apollo/client/core'; export type HttpRequestOptions = { headers?: HttpHeaders; + context?: HttpContext; withCredentials?: boolean; useMultipart?: boolean; + observe?: 'body' | 'events' | 'response'; + reportProgress?: boolean; + responseType?: 'json' | 'arraybuffer' | 'blob' | 'text'; + params?: any; + body?: any; }; +export type HttpClientReturn = + | Object + | ArrayBuffer + | Blob + | string + | HttpResponse + | HttpEvent; + export type URIFunction = (operation: Operation) => string; export type FetchOptions = { diff --git a/packages/apollo-angular/http/src/utils.ts b/packages/apollo-angular/http/src/utils.ts index f8d8cb03f..a0ae88a46 100644 --- a/packages/apollo-angular/http/src/utils.ts +++ b/packages/apollo-angular/http/src/utils.ts @@ -1,12 +1,21 @@ import { Observable } from 'rxjs'; -import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; -import { Body, ExtractedFiles, ExtractFiles, Request } from './types'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { + Body, + Context, + ExtractedFiles, + ExtractFiles, + HttpRequestOptions, + Request, + HttpClientReturn +} from './types'; export const fetch = ( req: Request, httpClient: HttpClient, extractFiles?: ExtractFiles, -): Observable> => { +): Observable => { + const context: Context = req.options || {}; const shouldUseBody = ['POST', 'PUT', 'PATCH'].indexOf(req.method.toUpperCase()) !== -1; const shouldStringify = (param: string) => ['variables', 'extensions'].indexOf(param.toLowerCase()) !== -1; @@ -96,13 +105,21 @@ export const fetch = ( (bodyOrParams as any).body = form; } - // create a request - return httpClient.request(req.method, req.url, { - observe: 'response', - responseType: 'json', - reportProgress: false, + const baseOptions: HttpRequestOptions = { + reportProgress: context.reportProgress ?? false, + withCredentials: context.withCredentials, + headers: context.headers, ...bodyOrParams, - ...req.options, + ...req.options + }; + + const observe = context.observe || 'response'; + const responseType = context.responseType || 'json'; + + return httpClient.request(req.method, req.url, { + ...baseOptions, + observe, + responseType: responseType as 'json' | 'text' | 'blob' | 'arraybuffer' }); }; diff --git a/packages/apollo-angular/http/tests/http-link.spec.ts b/packages/apollo-angular/http/tests/http-link.spec.ts index a31c60c83..349791983 100644 --- a/packages/apollo-angular/http/tests/http-link.spec.ts +++ b/packages/apollo-angular/http/tests/http-link.spec.ts @@ -1,9 +1,9 @@ import { print, stripIgnoredCharacters } from 'graphql'; import { mergeMap } from 'rxjs/operators'; -import { HttpHeaders, provideHttpClient } from '@angular/common/http'; +import { HttpHeaders, HttpResponse, HttpEvent, HttpEventType, provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { ApolloLink, execute, gql, InMemoryCache } from '@apollo/client/core'; +import { ApolloLink, execute, gql, InMemoryCache, FetchResult } from '@apollo/client/core'; import { Apollo } from '../../src'; import { HttpLink } from '../src/http-link'; @@ -744,4 +744,259 @@ describe('HttpLink', () => { expect(httpBackend.expectOne('graphql').cancelled).toBe(true); }); + describe('response types', () => { + const query = gql` + query heroes { + heroes { + name + } + } + `; + + test('should handle arraybuffer response type', (done) => { + const link = httpLink.create({ + uri: 'graphql' + }); + + execute(link, { + query, + context: { + observe: 'body', + responseType: 'arraybuffer' + } + }).subscribe({ + next: (result) => { + expect(result).toBeInstanceOf(ArrayBuffer); + done(); + }, + error: done.fail + }); + + const req = httpBackend.expectOne('graphql'); + expect(req.request.responseType).toBe('arraybuffer'); + const buffer = new ArrayBuffer(8); + req.flush(buffer); + }); + + test('should handle different observe options', (done) => { + const link = httpLink.create({ uri: 'graphql' }); + const data = { heroes: [{ name: 'Superman' }] }; + + let progressReceived = false; + let responseReceived = false; + + execute(link, { + query, + context: { + observe: 'events', + reportProgress: true + } + }).subscribe({ + next: (result: FetchResult) => { + if (result.extensions?.httpEvent) { + const event = result.extensions.httpEvent; + if (event.type === HttpEventType.DownloadProgress) { + expect(event.loaded).toBe(50); + expect(event.total).toBe(100); + progressReceived = true; + } + } else { + expect(result.data).toEqual(data); + responseReceived = true; + } + + if (progressReceived && responseReceived) { + done(); + } + }, + error: done.fail + }); + + const req = httpBackend.expectOne('graphql'); + expect(req.request.reportProgress).toBe(true); + + // Send progress event + req.event({ + type: HttpEventType.DownloadProgress, + loaded: 50, + total: 100 + } as HttpEvent); + + // Send final response + req.flush({ data }); + }); + + test('should handle text response type', (done) => { + const link = httpLink.create({ + uri: 'graphql' + }); + + execute(link, { + query, + context: { + observe: 'body', + responseType: 'text' + } + }).subscribe({ + next: (result) => { + expect(typeof result).toBe('string'); + expect(result).toBe('{"data":{"heroes":[{"name":"Superman"}]}}'); + done(); + }, + error: done.fail + }); + + const req = httpBackend.expectOne('graphql'); + expect(req.request.responseType).toBe('text'); + req.flush('{"data":{"heroes":[{"name":"Superman"}]}}'); + }); + + + test('should handle blob response type', (done) => { + const link = httpLink.create({ + uri: 'graphql' + }); + + execute(link, { + query, + context: { + responseType: 'blob', + observe: 'response' // Changed to 'response' to get the full HttpResponse + } + }).subscribe({ + next: (response: any) => { + // We should receive an HttpResponse with a Blob body + expect(response).toBeInstanceOf(HttpResponse); + expect(response.body).toBeInstanceOf(Blob); + done(); + }, + error: done.fail + }); + + const req = httpBackend.expectOne('graphql'); + expect(req.request.responseType).toBe('blob'); + + // Create the response with a proper Blob + const blob = new Blob(['{"data":{"heroes":[{"name":"Superman"}]}}'], { + type: 'application/json' + }); + + // Send response as HttpResponse + req.flush(blob, { + headers: { + 'Content-Type': 'application/json' + }, + status: 200, + statusText: 'OK' + }); + }); + + test('should handle blob response type with body observe', (done) => { + const link = httpLink.create({ + uri: 'graphql' + }); + + execute(link, { + query, + context: { + responseType: 'blob', + observe: 'body' + } + }).subscribe({ + next: (result: any) => { + // When observing body, we should get the Blob directly + expect(result).toBeInstanceOf(Blob); + + // We can even read the blob to verify its contents + const reader = new FileReader(); + reader.onload = () => { + const content = JSON.parse(reader.result as string); + expect(content.data.heroes[0].name).toBe('Superman'); + done(); + }; + reader.readAsText(result); + }, + error: done.fail + }); + + const req = httpBackend.expectOne('graphql'); + expect(req.request.responseType).toBe('blob'); + + const blob = new Blob(['{"data":{"heroes":[{"name":"Superman"}]}}'], { + type: 'application/json' + }); + req.flush(blob); + }); + + test('should store response in context', (done) => { + const link = httpLink.create({ uri: 'graphql' }); + const afterware = new ApolloLink((operation, forward) => { + return forward(operation).map(result => { + const context = operation.getContext(); + expect(context.response).toBeDefined(); + if (context.response instanceof HttpResponse) { + expect(context.response.body).toBeDefined(); + } + return result; + }); + }); + + const composedLink = afterware.concat(link); + const data = { heroes: [{ name: 'Superman' }] }; + + execute(composedLink, { query }).subscribe({ + next: () => done(), + error: done.fail + }); + + httpBackend.expectOne('graphql').flush({ data }); + }); + + test('should handle network errors', (done) => { + const link = httpLink.create({ uri: 'graphql' }); + const networkError = new ErrorEvent('network error'); + + execute(link, { query }).subscribe({ + next: () => done.fail('Should not emit next'), + error: (error) => { + expect(error).toBeDefined(); + done(); + } + }); + + httpBackend.expectOne('graphql').error(networkError); + }); + }); + + describe('HttpLink error handling', () => { + test('should properly format GraphQL errors', (done) => { + const link = httpLink.create({ uri: 'graphql' }); + const op = { + query: gql` + query heroes { + heroes { + name + } + } + ` + }; + + execute(link, op).subscribe({ + next: (result: FetchResult) => { + expect(result.errors).toBeDefined(); + expect(result.errors![0]).toHaveProperty('message'); + expect(result.errors![0]).toHaveProperty('extensions'); + done(); + }, + error: done.fail + }); + + httpBackend.expectOne('graphql').flush({ + errors: [{ + message: 'GraphQL error', + extensions: { code: 'ERROR_CODE' } + }] + }); + }); + }); }); diff --git a/packages/apollo-angular/src/subscription.ts b/packages/apollo-angular/src/subscription.ts index 290fd29ca..724fe407c 100644 --- a/packages/apollo-angular/src/subscription.ts +++ b/packages/apollo-angular/src/subscription.ts @@ -29,6 +29,6 @@ export abstract class Subscription>; } }