Skip to content

Commit

Permalink
feat(data): Add HttpOptions to EntityActionOptions (#3663) (#3664)
Browse files Browse the repository at this point in the history
Closes #3663
  • Loading branch information
cam-m authored Feb 9, 2023
1 parent d46d870 commit dd745c0
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 46 deletions.
9 changes: 9 additions & 0 deletions modules/data/spec/actions/entity-action-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ describe('EntityActionFactory', () => {
isOptimistic: true,
mergeStrategy: MergeStrategy.OverwriteChanges,
tag: 'Foo',
httpOptions: {
httpParams: {
fromString: 'extraQueryParam=CreateHeroLink',
},
},
};
const action = factory.create(payload);

Expand All @@ -89,6 +94,7 @@ describe('EntityActionFactory', () => {
isOptimistic,
mergeStrategy,
tag,
httpOptions,
} = action.payload;
expect(entityName).toBe(payload.entityName);
expect(entityOp).toBe(payload.entityOp);
Expand All @@ -97,6 +103,9 @@ describe('EntityActionFactory', () => {
expect(isOptimistic).toBe(payload.isOptimistic);
expect(mergeStrategy).toBe(payload.mergeStrategy);
expect(tag).toBe(payload.tag);
expect(httpOptions?.httpParams?.fromString).toBe(
payload.httpOptions?.httpParams?.fromString
);
});

it('#createFromAction should create EntityAction from another EntityAction', () => {
Expand Down
63 changes: 63 additions & 0 deletions modules/data/spec/dataservices/default-data.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DefaultDataServiceConfig,
DataServiceError,
} from '../../';
import { HttpOptions } from '../../src/dataservices/interfaces';

class Hero {
id!: number;
Expand Down Expand Up @@ -264,6 +265,68 @@ describe('DefaultDataService', () => {
req.flush(expectedHeroes);
});

it('should return expected selected heroes w/ string params and a custom header', (done) => {
const httpOptions: HttpOptions = {
httpHeaders: { MyHeader: 'MyHeaderValue' },
} as HttpOptions;
service.getWithQuery('name=B', httpOptions).subscribe((heroes) => {
expect(heroes).toEqual(expectedHeroes);
done();
}, fail);

// HeroService should have made one request to GET heroes
// from expected URL with query params
const req = httpTestingController.expectOne(heroesUrl + '?name=B');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.has('MyHeader')).toEqual(true);
expect(req.request.headers.get('MyHeader')).toEqual('MyHeaderValue');

// Respond with the mock heroes
req.flush(expectedHeroes);
});

it('should return expected selected heroes w/ httpOption string params', (done) => {
const httpOptions: HttpOptions = {
httpParams: { fromString: 'name=B' },
} as HttpOptions;

service.getWithQuery(undefined, httpOptions).subscribe((heroes) => {
expect(heroes).toEqual(expectedHeroes);
done();
}, fail);

// HeroService should have made one request to GET heroes
// from expected URL with query params
const req = httpTestingController.expectOne(heroesUrl + '?name=B');
expect(req.request.method).toEqual('GET');

// Respond with the mock heroes
req.flush(expectedHeroes);
});

it('should return expected selected heroes w/ httpOption option params', (done) => {
const httpOptions: HttpOptions = {
httpParams: {
fromObject: {
name: 'B',
},
},
} as HttpOptions;

service.getWithQuery(undefined, httpOptions).subscribe((heroes) => {
expect(heroes).toEqual(expectedHeroes);
done();
}, fail);

// HeroService should have made one request to GET heroes
// from expected URL with query params
const req = httpTestingController.expectOne(heroesUrl + '?name=B');
expect(req.request.method).toEqual('GET');

// Respond with the mock heroes
req.flush(expectedHeroes);
});

it('should be OK returning no heroes', (done) => {
service.getWithQuery({ name: 'B' }).subscribe((heroes) => {
expect(heroes.length).toEqual(0);
Expand Down
12 changes: 12 additions & 0 deletions modules/data/spec/dispatchers/entity-dispatcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ export function commandDispatchTest(
expect(data).toBe(42);
});

it('#delete(42) with a query param dispatches SAVE_DELETE_ONE optimistically for the id:42', () => {
dispatcher.delete(42, {
httpOptions: { httpParams: { fromObject: { queryParam1: 1 } } },
}); // optimistic by default
const { entityOp, isOptimistic, data, httpOptions } =
dispatchedAction().payload;
expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE);
expect(isOptimistic).toBe(true);
expect(data).toBe(42);
expect(httpOptions?.httpParams?.fromObject?.queryParam1).toBe(1);
});

it('#delete(hero) dispatches SAVE_DELETE_ONE optimistically for the hero.id', () => {
const id = 42;
const hero: Hero = { id, name: 'test' };
Expand Down
3 changes: 3 additions & 0 deletions modules/data/src/actions/entity-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Action } from '@ngrx/store';

import { EntityOp } from './entity-op';
import { MergeStrategy } from './merge-strategy';
import { HttpOptions } from '../dataservices/interfaces';

/** Action concerning an entity collection. */
export interface EntityAction<P = any> extends Action {
Expand All @@ -18,6 +19,8 @@ export interface EntityActionOptions {
readonly mergeStrategy?: MergeStrategy;
/** The tag to use in the action's type. The entityName if no tag specified. */
readonly tag?: string;
/** Options that will be passed to the dataService http request. Allows setting of Query Parameters and Headers */
readonly httpOptions?: HttpOptions;

// Mutable actions are BAD.
// Unfortunately, these mutations are the only way to stop @ngrx/effects
Expand Down
95 changes: 74 additions & 21 deletions modules/data/src/dataservices/default-data.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Injectable, Optional } from '@angular/core';
import { Injectable, isDevMode, Optional } from '@angular/core';
import {
HttpClient,
HttpErrorResponse,
HttpHeaders,
HttpParams,
} from '@angular/common/http';

Expand All @@ -15,6 +16,7 @@ import { DefaultDataServiceConfig } from './default-data-service-config';
import {
EntityCollectionDataService,
HttpMethods,
HttpOptions,
QueryParams,
RequestData,
} from './interfaces';
Expand Down Expand Up @@ -68,67 +70,118 @@ export class DefaultDataService<T> implements EntityCollectionDataService<T> {
this.timeout = to;
}

add(entity: T): Observable<T> {
add(entity: T, options?: HttpOptions): Observable<T> {
const entityOrError =
entity || new Error(`No "${this.entityName}" entity to add`);
return this.execute('POST', this.entityUrl, entityOrError);
return this.execute('POST', this.entityUrl, entityOrError, options);
}

delete(key: number | string): Observable<number | string> {
delete(
key: number | string,
options?: HttpOptions
): Observable<number | string> {
let err: Error | undefined;
if (key == null) {
err = new Error(`No "${this.entityName}" key to delete`);
}
return this.execute('DELETE', this.entityUrl + key, err).pipe(
return this.execute('DELETE', this.entityUrl + key, err, options).pipe(
// forward the id of deleted entity as the result of the HTTP DELETE
map((result) => key as number | string)
);
}

getAll(): Observable<T[]> {
return this.execute('GET', this.entitiesUrl);
getAll(options?: HttpOptions): Observable<T[]> {
return this.execute('GET', this.entitiesUrl, options);
}

getById(key: number | string): Observable<T> {
getById(key: number | string, options?: HttpOptions): Observable<T> {
let err: Error | undefined;
if (key == null) {
err = new Error(`No "${this.entityName}" key to get`);
}
return this.execute('GET', this.entityUrl + key, err);
return this.execute('GET', this.entityUrl + key, err, options);
}

getWithQuery(queryParams: QueryParams | string): Observable<T[]> {
getWithQuery(
queryParams: QueryParams | string | undefined,
options?: HttpOptions
): Observable<T[]> {
const qParams =
typeof queryParams === 'string'
? { fromString: queryParams }
: { fromObject: queryParams };
const params = new HttpParams(qParams);
return this.execute('GET', this.entitiesUrl, undefined, { params });

return this.execute(
'GET',
this.entitiesUrl,
undefined,
{ params },
options
);
}

update(update: Update<T>): Observable<T> {
update(update: Update<T>, options?: HttpOptions): Observable<T> {
const id = update && update.id;
const updateOrError =
id == null
? new Error(`No "${this.entityName}" update data or id`)
: update.changes;
return this.execute('PUT', this.entityUrl + id, updateOrError);
return this.execute('PUT', this.entityUrl + id, updateOrError, options);
}

// Important! Only call if the backend service supports upserts as a POST to the target URL
upsert(entity: T): Observable<T> {
upsert(entity: T, options?: HttpOptions): Observable<T> {
const entityOrError =
entity || new Error(`No "${this.entityName}" entity to upsert`);
return this.execute('POST', this.entityUrl, entityOrError);
return this.execute('POST', this.entityUrl, entityOrError, options);
}

protected execute(
method: HttpMethods,
url: string,
data?: any, // data, error, or undefined/null
options?: any
options?: any, // options or undefined/null
httpOptions?: HttpOptions // these override any options passed via options
): Observable<any> {
const req: RequestData = { method, url, data, options };
let ngHttpClientOptions: any = undefined;
if (httpOptions) {
ngHttpClientOptions = {
headers: httpOptions?.httpHeaders
? new HttpHeaders(httpOptions?.httpHeaders)
: undefined,
params: httpOptions?.httpParams
? new HttpParams(httpOptions?.httpParams)
: undefined,
};
}

// If any options have been specified, pass them to http client. Note
// the new http options, if specified, will override any options passed
// from the deprecated options parameter
let mergedOptions: any = undefined;
if (options || ngHttpClientOptions) {
if (isDevMode() && options && ngHttpClientOptions) {
console.warn(
'@ngrx/data: options.httpParams will be merged with queryParams when both are are provided to getWithQuery(). In the event of a conflict HttpOptions.httpParams will override queryParams`. The queryParams parameter of getWithQuery() will be removed in next major release.'
);
}

mergedOptions = {};
if (ngHttpClientOptions?.headers) {
mergedOptions.headers = ngHttpClientOptions?.headers;
}
if (ngHttpClientOptions?.params || options?.params) {
mergedOptions.params = ngHttpClientOptions?.params ?? options?.params;
}
}

const req: RequestData = {
method,
url,
data,
options: mergedOptions,
};

if (data instanceof Error) {
return this.handleError(req)(data);
Expand All @@ -138,29 +191,29 @@ export class DefaultDataService<T> implements EntityCollectionDataService<T> {

switch (method) {
case 'DELETE': {
result$ = this.http.delete(url, options);
result$ = this.http.delete(url, ngHttpClientOptions);
if (this.saveDelay) {
result$ = result$.pipe(delay(this.saveDelay));
}
break;
}
case 'GET': {
result$ = this.http.get(url, options);
result$ = this.http.get(url, mergedOptions);
if (this.getDelay) {
result$ = result$.pipe(delay(this.getDelay));
}
break;
}
case 'POST': {
result$ = this.http.post(url, data, options);
result$ = this.http.post(url, data, ngHttpClientOptions);
if (this.saveDelay) {
result$ = result$.pipe(delay(this.saveDelay));
}
break;
}
// N.B.: It must return an Update<T>
case 'PUT': {
result$ = this.http.put(url, data, options);
result$ = this.http.put(url, data, ngHttpClientOptions);
if (this.saveDelay) {
result$ = result$.pipe(delay(this.saveDelay));
}
Expand Down
Loading

0 comments on commit dd745c0

Please sign in to comment.