Skip to content

Commit a66537f

Browse files
committed
Factor out generic GraohQLService and make more testable
1 parent 14db418 commit a66537f

6 files changed

+234
-97
lines changed

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "angular-io-example-graphql",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"private": false,
55
"description": "Example Tour of Heroes project from an angular.io guide, but using a real relational database server (PostgreSQL), via GraphQL (generated using PostGraphile).",
66
"scripts": {
@@ -30,7 +30,7 @@
3030
"apollo-angular": "^1.0.1",
3131
"apollo-angular-link-http": "^1.0.1",
3232
"apollo-cache-inmemory": "^1.1.5",
33-
"apollo-client": "^2.2.0",
33+
"apollo-client": "^2.2.2",
3434
"core-js": "^2.4.1",
3535
"graphql": "^0.12.3",
3636
"graphql-tag": "^2.6.1",

src/app/app.module.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { HeroSearchComponent } from './hero-search/hero-search.component';
1313
import { HeroService } from './hero.service';
1414
import { MessageService } from './message.service';
1515
import { MessagesComponent } from './messages/messages.component';
16+
import { GraphQLService } from './graphql.service';
1617

1718
@NgModule({
1819
imports: [
@@ -31,7 +32,7 @@ import { MessagesComponent } from './messages/messages.component';
3132
MessagesComponent,
3233
HeroSearchComponent
3334
],
34-
providers: [ HeroService, MessageService ],
35+
providers: [ HeroService, GraphQLService, MessageService ],
3536
bootstrap: [ AppComponent ]
3637
})
3738
export class AppModule { }

src/app/graphql.service.ts

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {Injectable} from '@angular/core';
2+
import {HttpClient} from '@angular/common/http';
3+
import {Apollo} from 'apollo-angular';
4+
import {HttpLink} from 'apollo-angular-link-http';
5+
import {InMemoryCache} from 'apollo-cache-inmemory';
6+
import gql from 'graphql-tag';
7+
import {Observable} from 'rxjs/Observable';
8+
import {of} from 'rxjs/observable/of';
9+
import {catchError, map, tap} from 'rxjs/operators';
10+
import {MessageService} from './message.service';
11+
12+
export const GraphQLUrl = 'http://localhost:5000/graphql'; // URL to web api
13+
14+
export interface GQLOptions {
15+
readAll: any;
16+
readById: any;
17+
readWithTerm: any;
18+
create: any;
19+
update: any;
20+
delete: any;
21+
deleteById: any;
22+
}
23+
24+
@Injectable()
25+
export class GraphQLService {
26+
27+
constructor(
28+
private messageService: MessageService,
29+
private apollo: Apollo,
30+
private httpLink: HttpLink
31+
) {
32+
apollo.create({
33+
link: httpLink.create({ uri: GraphQLUrl }),
34+
cache: new InMemoryCache()
35+
});
36+
}
37+
38+
/** Read all records from the server */
39+
readAll<T>(options: GQLOptions): Observable<T> {
40+
return this.apollo.subscribe({query: options.readAll}).pipe(
41+
map(({data}) => data),
42+
tap(_ => this.log(`read all`)),
43+
catchError(this.handleError<T>('readAll'))
44+
);
45+
}
46+
47+
/** Read a record by id. Will 404 if id not found */
48+
readById<T>(options: GQLOptions, id: any): Observable<T> {
49+
return this.apollo.query<T>({query: options.readById, variables: {id: id}}).pipe(
50+
map(({data}) => data), // returns a {0|1} element array
51+
tap(_ => this.log(`read record with id=${id}`)),
52+
catchError(this.handleError<T>(`readById id=${id}`))
53+
);
54+
}
55+
56+
/* Read all records whose name contains the search term */
57+
readWithTerm<T>(options: GQLOptions, term: string): Observable<T> {
58+
if (!term.trim()) {return of();} // if not search term, return empty array of records.
59+
return this.apollo.watchQuery<T>({query: options.readWithTerm, variables: {term: term}}).valueChanges.pipe(
60+
map(({data}) => data),
61+
tap(_ => this.log(`read records matching "${term}"`)),
62+
catchError(this.handleError<T>('readWithTerm'))
63+
);
64+
}
65+
66+
//////// Save methods //////////
67+
68+
/** Create a new record on the server */
69+
create<T>(options: GQLOptions, record: any): Observable<T> {
70+
return this.apollo.mutate<T>({mutation: options.create, variables: record}).pipe(
71+
map(({data}) => data), // returns a {0|1} element array
72+
tap(_ => this.log(`created record with ${JSON.stringify(record)}`)),
73+
catchError(this.handleError<T>('create'))
74+
);
75+
}
76+
77+
/** Update the record on the server */
78+
update<T>(options: GQLOptions, record: any): Observable<T> {
79+
return this.apollo.mutate<T>({mutation: options.update, variables: record}).pipe(
80+
map(({data}) => data),
81+
tap(_ => this.log(`updated record with ${JSON.stringify(record)}`)),
82+
catchError(this.handleError<T>('update'))
83+
);
84+
}
85+
86+
/** Delete the record from the server */
87+
delete<T>(options: GQLOptions, record: any): Observable<T> {
88+
return this.apollo.mutate<T>({mutation: options.delete, variables: record}).pipe(
89+
map(({data}) => data),
90+
tap(_ => this.log(`deleted record with ${JSON.stringify(record)}`)),
91+
catchError(this.handleError<T>('delete'))
92+
);
93+
}
94+
95+
/** Delete the record from the server */
96+
deleteById<T>(options: GQLOptions, id: any): Observable<T> {
97+
return this.apollo.mutate<T>({mutation: options.deleteById, variables: {id: id}}).pipe(
98+
map(({data}) => data),
99+
tap(_ => this.log(`deleted record with id=${id}`)),
100+
catchError(this.handleError<T>('deleteById'))
101+
);
102+
}
103+
104+
/**
105+
* Handle Http operation that failed.
106+
* Let the app continue.
107+
* @param operation - name of the operation that failed
108+
* @param result - optional value to return as the observable result
109+
*/
110+
private handleError<T> (operation = 'operation', result?: T) {
111+
return (error: any): Observable<T> => {
112+
113+
// TODO: send the error to remote logging infrastructure
114+
console.error(error); // log to console instead
115+
116+
// TODO: better job of transforming error for user consumption
117+
this.log(`${operation} failed: ${error.message} no: ${error.number}`);
118+
119+
// Let the app keep running by returning an empty result.
120+
return of(result as T);
121+
};
122+
}
123+
124+
/** Log a GraphQLService message with the MessageService */
125+
private log(message: string) {
126+
this.messageService.add('GraphQLService: ' + message);
127+
}
128+
}

src/app/hero.service.spec.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { TestBed, async, inject } from '@angular/core/testing';
2+
import { HeroService } from './hero.service';
3+
import { HttpClientModule, HttpClient } from '@angular/common/http';
4+
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
5+
import { ApolloModule, Apollo } from 'apollo-angular';
6+
import { HttpLinkModule, HttpLink } from 'apollo-angular-link-http';
7+
import { InMemoryCache } from 'apollo-cache-inmemory';
8+
import { MessageService } from './message.service';
9+
10+
let messageService: MessageService;
11+
let apollo: Apollo;
12+
let httpLink: HttpLink;
13+
let heroService: HeroService;
14+
15+
describe('HeroService', () => {
16+
17+
beforeEach(() => {
18+
TestBed.configureTestingModule({
19+
imports: [ApolloModule, HttpLinkModule, HttpClientModule, HttpClientTestingModule],
20+
providers: [HeroService, MessageService, Apollo, HttpLink, HttpClient]
21+
});
22+
const messageService = TestBed.get(MessageService);
23+
const apollo = TestBed.get(Apollo);
24+
const heroService = TestBed.get(HeroService);
25+
// httpClient = new HttpClient(undefined);
26+
// httpLink = new HttpLink(httpClient);
27+
// apollo.create({
28+
// link: httpLink.create({ uri: heroesUrl }),
29+
// cache: new InMemoryCache()
30+
// });
31+
// heroService = new HeroServiceBase;
32+
});
33+
34+
it('should be created', (done: DoneFn) => {
35+
expect(heroService).toBeTruthy();
36+
done();
37+
});
38+
39+
it('getHero should return the specified hero by id', (done: DoneFn) => {
40+
heroService.getHero(12).subscribe(data => {
41+
expect(data).toBeDefined;
42+
expect(data.id).toBe(12);
43+
expect(data.name).toBe('Narco');
44+
done();
45+
});
46+
});
47+
48+
it('getHeroes should return all heroes', (done: DoneFn) => {
49+
heroService.getHeroes().subscribe(data => {
50+
expect(data.length).toBeGreaterThan(0);
51+
// expect(data).toEqual([]);
52+
done();
53+
});
54+
});
55+
});

src/app/hero.service.ts

+35-86
Original file line numberDiff line numberDiff line change
@@ -11,123 +11,72 @@ import { catchError, map, tap } from 'rxjs/operators';
1111

1212
import { Hero } from './hero';
1313
import { MessageService } from './message.service';
14-
import { validateConfig } from '@angular/router/src/config';
15-
16-
const heroesUrl = 'http://localhost:5000/graphql'; // URL to web api
17-
const allHeroes=gql`query tohAllHero{allHeroes{nodes{id,name}}}`;
18-
const heroById=gql`query tohHeroByID($id:Int!){heroById(id:$id){id,name}}`;
19-
const heroWithTerm=gql`query tohHeroWithTerm($term:String!){herowithterm(term:$term){nodes{id,name}}}`;
20-
const createHero=gql`mutation tohCreateHero($name:String!){createHero(input:{clientMutationId:"toh-createHero",hero:{name:$name}}){clientMutationId,hero{id,name}}}`;
21-
const updateHero=gql`mutation tohUpdateHeroById($id:Int!,$name:String!){updateHeroById(input:{clientMutationId:"toh-updateHero",id:$id,heroPatch:{name:$name}}){clientMutationId,hero{id,name}}}`;
22-
const deleteHero=gql`mutation tohDeleteHeroById($id:Int!){deleteHeroById(input:{clientMutationId:"toh-deleteHero",id:$id}){clientMutationId,hero{id,name}}}`;
14+
import { GraphQLService, GQLOptions } from './graphql.service';
15+
16+
const options: GQLOptions = {
17+
readAll: gql`query readAll{allHeroes{nodes{id,name}}}`,
18+
readById: gql`query readById($id:Int!){heroById(id:$id){id,name}}`,
19+
readWithTerm: gql`query readWithTerm($term:String!){allHeroes(term:$term){nodes{id,name}}}`,
20+
create: gql`mutation create($name:String!)
21+
{createHero(input:{clientMutationId:"toh-createHero",hero:{name:$name}})
22+
{clientMutationId,hero{id,name}}}`,
23+
update: gql`mutation update($id:Int!,$name:String!)
24+
{updateHeroById(input:{clientMutationId:"toh-updateHero",id:$id,heroPatch:{name:$name}})
25+
{clientMutationId,hero{id,name}}}`,
26+
delete: gql`mutation delete($id:Int!)
27+
{deleteHeroById(input:{clientMutationId:"toh-deleteHero",id:$id})
28+
{clientMutationId,hero{id,name}}}`,
29+
deleteById: gql`mutation deleteById($id:Int!)
30+
{deleteHeroById(input:{clientMutationId:"toh-deleteHeroById",id:$id})
31+
{clientMutationId,hero{id,name}}}`
32+
};
2333

2434
@Injectable()
2535
export class HeroService {
2636

2737
constructor(
28-
private messageService: MessageService,
29-
private apollo: Apollo,
30-
private httpLink: HttpLink
31-
) {
32-
apollo.create({
33-
link: httpLink.create({ uri: heroesUrl }),
34-
cache: new InMemoryCache()
35-
});
36-
}
38+
private graphQLService: GraphQLService
39+
) { }
3740

3841
/** Get all heroes from the server */
3942
getHeroes (): Observable<Hero[]> {
40-
return this.apollo.subscribe({query: allHeroes}).pipe(
41-
map(({data})=>data.allHeroes.nodes),
42-
tap(heroes => this.log(`fetched heroes`)),
43-
catchError(this.handleError('getHeroes', []))
44-
);
45-
}
46-
47-
/** Get a hero by id. Return `undefined` when id not found */
48-
getHeroNo404<Data>(id: number): Observable<Hero> {
49-
return this.apollo.query<{heroById:Hero}>({query: heroById, variables: {id: id}}).pipe(
50-
map(({data}) => data.heroById), // returns a {0|1} element array
51-
tap(h => {
52-
const outcome = h ? `fetched` : `did not find`;
53-
this.log(`${outcome} hero id=${id}`);
54-
}),
55-
catchError(this.handleError<Hero>(`getHero id=${id}`))
56-
);
43+
return this.graphQLService.readAll<{allHeroes:{nodes:Hero[]}}>(options).pipe(
44+
map((data) => data.allHeroes.nodes))
5745
}
5846

5947
/** Get a hero by id. Will 404 if id not found */
6048
getHero(id: number): Observable<Hero> {
61-
return this.apollo.query<{heroById:Hero}>({query: heroById, variables: {id: id}}).pipe(
62-
map(({data}) => data.heroById), // returns a {0|1} element array
63-
tap(_ => this.log(`fetched hero id=${id}`)),
64-
catchError(this.handleError<Hero>(`getHero id=${id}`))
65-
);
49+
return this.graphQLService.readById<{heroById:Hero}>(options, id).pipe(
50+
map((data) => data.heroById))
6651
}
6752

6853
/* Get all heroes whose name contains search term */
6954
searchHeroes(term: string): Observable<Hero[]> {
70-
if (!term.trim()) {
71-
// if not search term, return empty hero array.
72-
return of([]);
73-
}
74-
return this.apollo.watchQuery<{herowithterm:{nodes:Hero[]}}>({query: heroWithTerm, variables: {term: term}}).valueChanges.pipe(
75-
map(({data})=>data.herowithterm.nodes),
76-
tap(_ => this.log(`found heroes matching "${term}"`)),
77-
catchError(this.handleError<Hero[]>('searchHeroes', []))
78-
);
55+
if (!term.trim()) {return of([]);} // if not search term, return empty hero array.
56+
return this.graphQLService.readWithTerm<{allHeroes:{nodes:Hero[]}}>(options, term).pipe(
57+
map((data) => data.allHeroes.nodes))
7958
}
8059

8160
//////// Save methods //////////
8261

8362
/** Add a new hero to the server */
8463
addHero (hero: Hero): Observable<Hero> {
85-
return this.apollo.mutate<{createHero:{hero:Hero}}>({mutation: createHero, variables: {name: hero.name}}).pipe(
86-
map(({data}) => data.createHero.hero), // returns a {0|1} element array
87-
tap(_ => this.log(`added hero id=${_.id}`)),
88-
catchError(this.handleError<Hero>('addHero'))
89-
);
64+
return this.graphQLService.create<{createHero:{hero:Hero}}>(options, hero).pipe(
65+
map((data) => data.createHero.hero))
9066
}
9167

9268
/** Delete the hero from the server */
9369
deleteHero (hero: Hero | number): Observable<Hero> {
9470
const id = typeof hero === 'number' ? hero : hero.id;
95-
return this.apollo.mutate({mutation: deleteHero, variables: {id: id}}).pipe(
96-
tap(_ => this.log(`deleted hero id=${id}`)),
97-
catchError(this.handleError<Hero>('deleteHero'))
98-
);
71+
return this.graphQLService.delete<{deleteHeroById:{hero:Hero}}>(options, {"id": id}).pipe(
72+
map((data) => data.deleteHeroById.hero))
9973
}
10074

75+
10176
/** Update the hero on the server */
10277
updateHero (hero: Hero): Observable<Hero> {
103-
return this.apollo.mutate({mutation:updateHero, variables: hero}).pipe(
104-
tap(_ => this.log(`updated hero id=${hero.id} new name=${hero.name}`)),
105-
catchError(this.handleError<Hero>('updateHero'))
106-
);
78+
return this.graphQLService.update<{updateHeroById:{hero:Hero}}>(options, hero).pipe(
79+
map((data) => data.updateHeroById.hero))
10780
}
10881

109-
/**
110-
* Handle Http operation that failed.
111-
* Let the app continue.
112-
* @param operation - name of the operation that failed
113-
* @param result - optional value to return as the observable result
114-
*/
115-
private handleError<T> (operation = 'operation', result?: T) {
116-
return (error: any): Observable<T> => {
117-
118-
// TODO: send the error to remote logging infrastructure
119-
console.error(error); // log to console instead
120-
121-
// TODO: better job of transforming error for user consumption
122-
this.log(`${operation} failed: ${error.message} no: ${error.number}`);
123-
124-
// Let the app keep running by returning an empty result.
125-
return of(result as T);
126-
};
127-
}
128-
129-
/** Log a HeroService message with the MessageService */
130-
private log(message: string) {
131-
this.messageService.add('HeroService: ' + message);
132-
}
13382
}

0 commit comments

Comments
 (0)