From 97f9ce0ded3f64e4a73e564fcc11b32ae9b55964 Mon Sep 17 00:00:00 2001 From: Alex H Date: Sat, 10 Oct 2026 14:41:16 +0200 Subject: [PATCH 1/2] fix(json-api-nestjs-sdk): translate between HttpClient and HttpInnerClient in the Angular providers Angular's HttpClient was injected as HttpInnerClient directly, with an `as any` hiding that the two disagree about argument positions. delete is the one that cost something: HttpInnerClient takes the body second, HttpClient reads that position as options and expects the body inside them. So deleteRelationships handed `{ data: [...] }` to a slot meant for headers, params and context, and the body never reached the request. The fetch client reads the same argument as a body, so this only ever went wrong under Angular. post and patch were harmless but equally misaligned -- their options come third for HttpClient, and HttpInnerClient has no slot for them at all, which is why nothing could be attached to a request until now. An adapter in the Angular file translates the four verbs. It sits where HttpClient is already imported, so the fetch and axios adapters are untouched. The spec imports @angular/compiler first: loading @angular/common/http outside an AOT build leaves its providers to be JIT-compiled. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/json-api-angular.spec.ts | 72 +++++++++++++++++++ .../src/lib/json-api-angular.ts | 42 ++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts new file mode 100644 index 0000000..d26946f --- /dev/null +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts @@ -0,0 +1,72 @@ +// Importing the Angular entry point pulls in @angular/common/http, whose +// providers are JIT-compiled when they load outside an AOT build. +import '@angular/compiler'; +import { of } from 'rxjs'; +import { createHttpInnerClient } from './json-api-angular'; + +type Call = { verb: string; args: unknown[] }; + +function fakeHttpClient() { + const calls: Call[] = []; + const record = + (verb: string) => + (...args: unknown[]) => { + calls.push({ verb, args }); + return of({}); + }; + + return { + calls, + client: { + get: record('get'), + post: record('post'), + patch: record('patch'), + delete: record('delete'), + } as never, + }; +} + +describe('createHttpInnerClient', () => { + describe('where the body goes', () => { + it('sends a delete body the way HttpClient expects it', () => { + // HttpInnerClient takes the body as the second argument of delete, but + // HttpClient reads that position as options and carries the body inside + // them. Handing the body over as-is dropped it from the request. + const { calls, client } = fakeHttpClient(); + const body = { data: [{ type: 'roles', id: '1' }] }; + + createHttpInnerClient(client).delete('/url', body as never); + + expect(calls).toHaveLength(1); + expect(calls[0].args).toEqual(['/url', { body }]); + }); + + it('sends no options at all when delete has no body', () => { + const { calls, client } = fakeHttpClient(); + + createHttpInnerClient(client).delete('/url'); + + expect(calls[0].args).toEqual(['/url', undefined]); + }); + + it('leaves the body of post and patch in its own argument', () => { + const { calls, client } = fakeHttpClient(); + const body = { data: { type: 'users', attributes: {} } }; + + createHttpInnerClient(client).post('/url', body as never); + createHttpInnerClient(client).patch('/url', body as never); + + expect(calls[0].args).toEqual(['/url', body, undefined]); + expect(calls[1].args).toEqual(['/url', body, undefined]); + }); + + it('passes the query params of get through untouched', () => { + const { calls, client } = fakeHttpClient(); + const params = { filter: 'x' }; + + createHttpInnerClient(client).get('/url', { params } as never); + + expect(calls[0].args).toEqual(['/url', { params }]); + }); + }); +}); diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts index 9343229..d3adbbe 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts @@ -1,6 +1,7 @@ import { inject, makeEnvironmentProviders } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { HttpInnerClient, JsonSdkConfig } from './types/index.js'; +import { ParamObject } from './utils/index.js'; import { AtomicFactory, JSON_API_SDK_CONFIG } from './token/index.js'; import { resultConfig } from './utils/index.js'; import { @@ -9,6 +10,43 @@ import { AtomicOperationsService, } from './service/index.js'; +/** + * HttpClient and HttpInnerClient disagree about where things go, and injecting + * the one as the other papered over it: + * + * delete HttpInnerClient takes the body second; HttpClient reads that + * position as options and expects the body inside them, so a body + * handed over as-is never reached the request. + * post options come third for HttpClient, and HttpInnerClient has no slot + * patch for them at all. + * + * Translating here keeps that knowledge in the one file that already knows + * about Angular, and leaves the fetch and axios adapters untouched. + */ +export function createHttpInnerClient(httpClient: HttpClient): HttpInnerClient { + return { + get: (url: string, options?: { params: ParamObject }) => + (httpClient.get as (u: string, o?: unknown) => unknown)(url, options), + post: (url: string, body: unknown) => + (httpClient.post as (u: string, b: unknown, o?: unknown) => unknown)( + url, + body, + undefined + ), + patch: (url: string, body: unknown) => + (httpClient.patch as (u: string, b: unknown, o?: unknown) => unknown)( + url, + body, + undefined + ), + delete: (url: string, body?: unknown) => + (httpClient.delete as (u: string, o?: unknown) => unknown)( + url, + body === undefined ? undefined : { body } + ), + } as HttpInnerClient; +} + export type JsonSdkConfigFactory = () => JsonSdkConfig; export type JsonSdkConfigOrFactory = JsonSdkConfig | JsonSdkConfigFactory; @@ -31,7 +69,7 @@ export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [ provide: JsonApiSdkService, useFactory: () => new JsonApiSdkService( - inject(HttpClient as any), + createHttpInnerClient(inject(HttpClient)), inject(JsonApiUtilsService), inject(JSON_API_SDK_CONFIG) ), @@ -41,7 +79,7 @@ export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [ useFactory: () => { const jsonApiUtilsService = inject(JsonApiUtilsService); const config = inject(JSON_API_SDK_CONFIG); - const httpClient = inject(HttpClient as any); + const httpClient = createHttpInnerClient(inject(HttpClient)); return () => { return new AtomicOperationsService( From e5f513d20763b721e9aebee80c3f28459f5f639c Mon Sep 17 00:00:00 2001 From: Alex H Date: Sat, 10 Oct 2026 14:45:41 +0200 Subject: [PATCH 2/2] feat(json-api-nestjs-sdk): let the Angular config carry an HttpContext A provider built inside a nested component gets its own SDK config while the interceptors stay global, and until now nothing told them apart: every request looked the same whichever provider made it. The config may now carry an HttpContext, attached to every request the SDK sends, which is the channel interceptors already read through req.context.get(TOKEN). HttpContext stays out of JsonSdkConfig -- the core keeps no Angular types, or it would reach the fetch and axios builds where it means nothing. The Angular file extends it instead, as JsonSdkAngularConfig. The config is resolved once now, behind an internal token. Three providers read it, and a config factory called once per provider would have handed JsonApiSdkService and AtomicFactory two different HttpContext objects. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/json-api-angular.spec.ts | 55 +++++++++++++ .../src/lib/json-api-angular.ts | 82 +++++++++++++++---- .../json-api-nestjs-sdk/src/ngModule.ts | 2 + 3 files changed, 124 insertions(+), 15 deletions(-) diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts index d26946f..87e2489 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts @@ -2,6 +2,7 @@ // providers are JIT-compiled when they load outside an AOT build. import '@angular/compiler'; import { of } from 'rxjs'; +import { HttpContext, HttpContextToken } from '@angular/common/http'; import { createHttpInnerClient } from './json-api-angular'; type Call = { verb: string; args: unknown[] }; @@ -27,6 +28,60 @@ function fakeHttpClient() { } describe('createHttpInnerClient', () => { + + describe('the context a global interceptor reads', () => { + const SCOPE = new HttpContextToken(() => 'root'); + + it('rides along on every verb', () => { + // A provider created inside a nested component gets its own config; the + // interceptors stay global, so the context is what tells them which + // instance a request came from. + const { calls, client } = fakeHttpClient(); + const context = new HttpContext().set(SCOPE, 'nested'); + const http = createHttpInnerClient(client, context); + + http.get('/url', { params: { a: '1' } } as never); + http.post('/url', {} as never); + http.patch('/url', {} as never); + http.delete('/url', { data: [] } as never); + + const optionsOf: Record = { + get: calls[0].args[1], + post: calls[1].args[2], + patch: calls[2].args[2], + delete: calls[3].args[1], + }; + + for (const [verb, options] of Object.entries(optionsOf)) { + const ctx = (options as { context?: HttpContext } | undefined)?.context; + expect(ctx, verb).toBeInstanceOf(HttpContext); + expect(ctx?.get(SCOPE), verb).toBe('nested'); + } + }); + + it('keeps what each verb already carried', () => { + const { calls, client } = fakeHttpClient(); + const context = new HttpContext().set(SCOPE, 'nested'); + const http = createHttpInnerClient(client, context); + const params = { filter: 'x' }; + const body = { data: [] }; + + http.get('/url', { params } as never); + http.delete('/url', body as never); + + expect(calls[0].args[1]).toMatchObject({ params }); + expect(calls[1].args[1]).toMatchObject({ body }); + }); + + it('adds nothing when the config names no context', () => { + const { calls, client } = fakeHttpClient(); + + createHttpInnerClient(client).post('/url', {} as never); + + expect(calls[0].args[2]).toBeUndefined(); + }); + }); + describe('where the body goes', () => { it('sends a delete body the way HttpClient expects it', () => { // HttpInnerClient takes the body as the second argument of delete, but diff --git a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts index d3adbbe..83a32fc 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts @@ -1,5 +1,9 @@ -import { inject, makeEnvironmentProviders } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { + inject, + makeEnvironmentProviders, + InjectionToken, +} from '@angular/core'; +import { HttpClient, HttpContext } from '@angular/common/http'; import { HttpInnerClient, JsonSdkConfig } from './types/index.js'; import { ParamObject } from './utils/index.js'; import { AtomicFactory, JSON_API_SDK_CONFIG } from './token/index.js'; @@ -22,44 +26,86 @@ import { * * Translating here keeps that knowledge in the one file that already knows * about Angular, and leaves the fetch and axios adapters untouched. + * + * The context, when the config carries one, is attached to every request. A + * provider built inside a nested component gets its own config while the + * interceptors stay global, and the context is what tells them which instance + * a request came from. */ -export function createHttpInnerClient(httpClient: HttpClient): HttpInnerClient { +export function createHttpInnerClient( + httpClient: HttpClient, + context?: HttpContext +): HttpInnerClient { + const withContext = (options?: object) => { + if (!context) { + return options; + } + + return { ...(options ?? {}), context }; + }; + return { get: (url: string, options?: { params: ParamObject }) => - (httpClient.get as (u: string, o?: unknown) => unknown)(url, options), + (httpClient.get as (u: string, o?: unknown) => unknown)( + url, + withContext(options) + ), post: (url: string, body: unknown) => (httpClient.post as (u: string, b: unknown, o?: unknown) => unknown)( url, body, - undefined + withContext() ), patch: (url: string, body: unknown) => (httpClient.patch as (u: string, b: unknown, o?: unknown) => unknown)( url, body, - undefined + withContext() ), delete: (url: string, body?: unknown) => (httpClient.delete as (u: string, o?: unknown) => unknown)( url, - body === undefined ? undefined : { body } + withContext(body === undefined ? undefined : { body }) ), } as HttpInnerClient; } -export type JsonSdkConfigFactory = () => JsonSdkConfig; -export type JsonSdkConfigOrFactory = JsonSdkConfig | JsonSdkConfigFactory; +/** + * The base config plus what only makes sense under Angular. HttpContext stays + * out of JsonSdkConfig so the core keeps no Angular types. + */ +export type JsonSdkAngularConfig = JsonSdkConfig & { + /** + * Attached to every request the SDK makes, for global interceptors to read. + */ + httpContext?: HttpContext; +}; + +export type JsonSdkConfigFactory = () => JsonSdkAngularConfig; +export type JsonSdkConfigOrFactory = + | JsonSdkAngularConfig + | JsonSdkConfigFactory; const isFactory = (config: JsonSdkConfigOrFactory): config is JsonSdkConfigFactory => typeof config === 'function'; +/** + * The config as given, before defaults are filled in. It exists so a config + * factory runs once no matter how many providers below read it. + */ +const RESOLVED_SDK_CONFIG = new InjectionToken( + 'Config as supplied to provideJsonApi' +); + export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [ + { + provide: RESOLVED_SDK_CONFIG, + useFactory: () => + isFactory(configOrFactory) ? configOrFactory() : configOrFactory, + }, { provide: JSON_API_SDK_CONFIG, - useFactory: () => { - const config = isFactory(configOrFactory) ? configOrFactory() : configOrFactory; - return resultConfig(config); - }, + useFactory: () => resultConfig(inject(RESOLVED_SDK_CONFIG)), }, { provide: JsonApiUtilsService, @@ -69,7 +115,10 @@ export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [ provide: JsonApiSdkService, useFactory: () => new JsonApiSdkService( - createHttpInnerClient(inject(HttpClient)), + createHttpInnerClient( + inject(HttpClient), + inject(RESOLVED_SDK_CONFIG).httpContext + ), inject(JsonApiUtilsService), inject(JSON_API_SDK_CONFIG) ), @@ -79,7 +128,10 @@ export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [ useFactory: () => { const jsonApiUtilsService = inject(JsonApiUtilsService); const config = inject(JSON_API_SDK_CONFIG); - const httpClient = createHttpInnerClient(inject(HttpClient)); + const httpClient = createHttpInnerClient( + inject(HttpClient), + inject(RESOLVED_SDK_CONFIG).httpContext + ); return () => { return new AtomicOperationsService( diff --git a/libs/json-api/json-api-nestjs-sdk/src/ngModule.ts b/libs/json-api/json-api-nestjs-sdk/src/ngModule.ts index 54a4258..a926699 100644 --- a/libs/json-api/json-api-nestjs-sdk/src/ngModule.ts +++ b/libs/json-api/json-api-nestjs-sdk/src/ngModule.ts @@ -7,6 +7,8 @@ export { getProviders, JsonSdkConfigFactory, JsonSdkConfigOrFactory, + JsonSdkAngularConfig, + createHttpInnerClient, } from './lib/json-api-angular.js'; export { JsonApiUtilsService, JsonApiSdkService } from './lib/service/index.js'; export { AtomicOperations, JsonSdkConfig } from './lib/types/index.js';