Перейти к содержимому

59. GraphQL с Angular

Привет! Яша здесь. REST API — хорошо, но GraphQL даёт тебе точно те данные, что нужны, не больше и не меньше. Apollo Angular — лучший способ интегрировать GraphQL в Angular. Разберём всё от запросов до оптимистичных обновлений 🚀


Окно терминала
ng add apollo-angular
# - Установит apollo-angular, @apollo/client, graphql
# - Добавит GraphQLModule в AppModule
# - Попросит URL GraphQL сервера
// graphql.module.ts — ручная настройка
import { NgModule } from '@angular/core';
import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular';
import { ApolloClientOptions, InMemoryCache, ApolloLink } from '@apollo/client/core';
import { HttpLink } from 'apollo-angular/http';
import { onError } from '@apollo/client/link/error';
export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
const http = httpLink.create({ uri: 'https://api.example.com/graphql' });
// Error link — глобальная обработка ошибок
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.error(`GraphQL Error: ${message}`, { locations, path });
});
}
if (networkError) {
console.error('Network error:', networkError);
}
});
// Auth link — добавляем JWT токен
const authLink = new ApolloLink((operation, forward) => {
const token = localStorage.getItem('token');
operation.setContext({
headers: { Authorization: token ? \`Bearer \${token}\` : '' }
});
return forward(operation);
});
return {
link: ApolloLink.from([errorLink, authLink, http]),
cache: new InMemoryCache({
typePolicies: {
User: { keyFields: ['id'] }, // ← кэш нормализация по id
Product: { keyFields: ['id'] },
}
}),
defaultOptions: {
watchQuery: { fetchPolicy: 'cache-and-network' }, // сначала кэш, потом сеть
query: { fetchPolicy: 'network-only' },
},
};
}
@NgModule({
imports: [ApolloModule],
providers: [{
provide: APOLLO_OPTIONS,
useFactory: createApollo,
deps: [HttpLink],
}],
})
export class GraphQLModule {}

import { gql } from '@apollo/client/core';
import { Apollo } from 'apollo-angular';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// Типизация
export const GET_USERS = gql`
query GetUsers($page: Int, $limit: Int) {
users(page: $page, limit: $limit) {
id
name
email
role
createdAt
}
}
`;
export const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
role
posts {
id
title
createdAt
}
}
}
`;
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(private apollo: Apollo) {}
// watchQuery — возвращает Observable, обновляется при изменении кэша
getUsers(page = 1, limit = 10): Observable<User[]> {
return this.apollo.watchQuery<{ users: User[] }>({
query: GET_USERS,
variables: { page, limit },
}).valueChanges.pipe(
map(result => result.data.users)
);
}
// query — одноразовый запрос
getUserById(id: string): Observable<User> {
return this.apollo.query<{ user: User }>({
query: GET_USER,
variables: { id },
}).pipe(
map(result => result.data.user)
);
}
}

const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
role
}
}
`;
const UPDATE_USER = gql`
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
}
}
`;
const DELETE_USER = gql`
mutation DeleteUser($id: ID!) {
deleteUser(id: $id) {
success
message
}
}
`;
@Injectable({ providedIn: 'root' })
export class UserMutationService {
constructor(private apollo: Apollo) {}
createUser(input: CreateUserInput): Observable<User> {
return this.apollo.mutate<{ createUser: User }>({
mutation: CREATE_USER,
variables: { input },
// Обновление кэша после мутации
update: (cache, { data }) => {
const existing = cache.readQuery<{ users: User[] }>({ query: GET_USERS });
if (existing && data?.createUser) {
cache.writeQuery({
query: GET_USERS,
data: { users: [...existing.users, data.createUser] },
});
}
},
}).pipe(
map(result => result.data!.createUser)
);
}
deleteUser(id: string): Observable<boolean> {
return this.apollo.mutate<{ deleteUser: { success: boolean } }>({
mutation: DELETE_USER,
variables: { id },
// evict удаляет объект из кэша
update: (cache) => {
cache.evict({ id: cache.identify({ __typename: 'User', id }) });
cache.gc(); // garbage collect
},
}).pipe(
map(result => result.data!.deleteUser.success)
);
}
}

updateUserName(id: string, name: string): Observable<User> {
return this.apollo.mutate<{ updateUser: User }>({
mutation: UPDATE_USER,
variables: { id, input: { name } },
// Показываем результат НЕМЕДЛЕННО, не дожидаясь ответа сервера
optimisticResponse: {
__typename: 'Mutation',
updateUser: {
__typename: 'User',
id,
name,
email: '', // заглушка — обновится когда придёт реальный ответ
},
},
}).pipe(
map(result => result.data!.updateUser)
);
}

const USER_UPDATED_SUBSCRIPTION = gql`
subscription OnUserUpdated {
userUpdated {
id
name
email
updatedAt
}
}
`;
// Настройка WebSocket link
import { split } from '@apollo/client/core';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
const wsLink = new GraphQLWsLink(createClient({
url: 'wss://api.example.com/graphql',
connectionParams: {
authToken: localStorage.getItem('token'),
},
}));
// HTTP для queries/mutations, WS для subscriptions
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition'
&& definition.operation === 'subscription';
},
wsLink, // ← subscriptions
httpLink, // ← queries & mutations
);
// Использование
@Component({...})
export class UsersComponent implements OnInit, OnDestroy {
users$ = this.userService.getUsers();
constructor(private apollo: Apollo) {}
ngOnInit(): void {
// Подписка обновляет кэш — компонент автоматически перерисуется!
this.apollo.subscribe<{ userUpdated: User }>({
query: USER_UPDATED_SUBSCRIPTION,
}).subscribe(({ data }) => {
// Apollo автоматически обновит кэш через keyFields: ['id']
console.log('Пользователь обновлён:', data?.userUpdated);
});
}
}

Окно терминала
npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-apollo-angular
codegen.yml
schema: https://api.example.com/graphql
documents: 'src/**/*.graphql'
generates:
src/app/graphql/generated.ts:
plugins:
- typescript
- typescript-operations
- typescript-apollo-angular
config:
addExplicitOverride: true
apolloAngularVersion: 3
src/app/users/users.graphql
query GetUsers($page: Int, $limit: Int) {
users(page: $page, limit: $limit) {
id
name
email
role
}
}
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
Окно терминала
# Генерация типов и сервисов
npm run codegen
# Результат — типизированные Angular сервисы!
# GetUsersGQL — query сервис
# CreateUserGQL — mutation сервис
// Использование сгенерированных сервисов
import { GetUsersGQL, CreateUserGQL, GetUsersQuery } from './graphql/generated';
@Component({...})
export class UsersComponent {
users$ = this.getUsersGQL.watch({ page: 1, limit: 10 })
.valueChanges.pipe(
map(result => result.data.users)
);
constructor(
private getUsersGQL: GetUsersGQL,
private createUserGQL: CreateUserGQL,
) {}
createUser(name: string, email: string): void {
this.createUserGQL.mutate({ input: { name, email } })
.subscribe(result => console.log('Создан:', result.data?.createUser));
}
}

// Fragments — переиспользуемые части запросов
const USER_FRAGMENT = gql`
fragment UserFields on User {
id
name
email
role
avatar
}
`;
const GET_USERS_WITH_FRAGMENT = gql`
${USER_FRAGMENT}
query GetUsersDetailed {
users {
...UserFields
posts { id title }
}
}
`;
// Чтение/запись отдельных объектов в кэш
cache.writeFragment({
id: cache.identify({ __typename: 'User', id: '1' }),
fragment: USER_FRAGMENT,
data: {
id: '1',
name: 'Обновлённое имя',
role: 'admin',
avatar: null,
},
});

Симулятор GraphQL запросов и мутаций: