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
127 changes: 127 additions & 0 deletions libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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 { HttpContext, HttpContextToken } from '@angular/common/http';
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('the context a global interceptor reads', () => {
const SCOPE = new HttpContextToken<string>(() => '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<string, unknown> = {
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
// 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 }]);
});
});
});
110 changes: 100 additions & 10 deletions libs/json-api/json-api-nestjs-sdk/src/lib/json-api-angular.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
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';
import { resultConfig } from './utils/index.js';
import {
Expand All @@ -9,19 +14,98 @@ import {
AtomicOperationsService,
} from './service/index.js';

export type JsonSdkConfigFactory = () => JsonSdkConfig;
export type JsonSdkConfigOrFactory = JsonSdkConfig | JsonSdkConfigFactory;
/**
* 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.
*
* 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,
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,
withContext(options)
),
post: (url: string, body: unknown) =>
(httpClient.post as (u: string, b: unknown, o?: unknown) => unknown)(
url,
body,
withContext()
),
patch: (url: string, body: unknown) =>
(httpClient.patch as (u: string, b: unknown, o?: unknown) => unknown)(
url,
body,
withContext()
),
delete: (url: string, body?: unknown) =>
(httpClient.delete as (u: string, o?: unknown) => unknown)(
url,
withContext(body === undefined ? undefined : { body })
),
} as HttpInnerClient;
}

/**
* 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<JsonSdkAngularConfig>(
'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,
Expand All @@ -31,7 +115,10 @@ export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [
provide: JsonApiSdkService,
useFactory: () =>
new JsonApiSdkService(
inject<HttpInnerClient>(HttpClient as any),
createHttpInnerClient(
inject(HttpClient),
inject(RESOLVED_SDK_CONFIG).httpContext
),
inject(JsonApiUtilsService),
inject(JSON_API_SDK_CONFIG)
),
Expand All @@ -41,7 +128,10 @@ export const getProviders = (configOrFactory: JsonSdkConfigOrFactory) => [
useFactory: () => {
const jsonApiUtilsService = inject(JsonApiUtilsService);
const config = inject(JSON_API_SDK_CONFIG);
const httpClient = inject<HttpInnerClient>(HttpClient as any);
const httpClient = createHttpInnerClient(
inject(HttpClient),
inject(RESOLVED_SDK_CONFIG).httpContext
);

return () => {
return new AtomicOperationsService(
Expand Down
2 changes: 2 additions & 0 deletions libs/json-api/json-api-nestjs-sdk/src/ngModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading