GraphQL bietet eine moderne, flexible Alternative zu REST APIs durch seine typisierte Query-Sprache und effiziente Datenabfrage. NestJS integriert GraphQL nahtlos mit TypeScript und bietet sowohl Schema-First als auch Code-First Ansätze. Dieses Kapitel zeigt die umfassende Integration von GraphQL in NestJS-Anwendungen mit fortgeschrittenen Features wie DataLoader, Subscriptions und Authentication.
NestJS unterstützt GraphQL durch das @nestjs/graphql
Paket mit Apollo Server Integration und bietet sowohl Schema-First als
auch Code-First Entwicklungsansätze.
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
@Module({
imports: [
// TypeORM Konfiguration
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'password',
database: 'graphql_demo',
autoLoadEntities: true,
synchronize: process.env.NODE_ENV !== 'production',
}),
// GraphQL Code-First Konfiguration
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
// Development Features
playground: process.env.NODE_ENV !== 'production',
introspection: true,
// Context für Authentication
context: ({ req, res }) => ({
req,
res,
user: req.user,
}),
// Formatters für bessere Error Handling
formatError: (error) => {
console.error('GraphQL Error:', error);
// Don't expose internal errors in production
if (process.env.NODE_ENV === 'production') {
return {
message: error.message,
code: error.extensions?.code,
path: error.path,
};
}
return error;
},
// Performance Optimierungen
includeStacktraceInErrorResponses: process.env.NODE_ENV !== 'production',
// CORS für Frontend Integration
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
},
// Upload Support
uploads: {
maxFileSize: 10000000, // 10MB
maxFiles: 5,
},
}),
],
})
export class AppModule {}import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GqlOptionsFactory, GqlModuleOptions } from '@nestjs/graphql';
import { ApolloDriver } from '@nestjs/apollo';
import { join } from 'path';
@Injectable()
export class GraphQLConfigService implements GqlOptionsFactory {
constructor(private configService: ConfigService) {}
createGqlOptions(): GqlModuleOptions {
const isProduction = this.configService.get('NODE_ENV') === 'production';
const isDevelopment = this.configService.get('NODE_ENV') === 'development';
return {
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
// Environment-specific settings
playground: !isProduction,
introspection: !isProduction,
debug: isDevelopment,
// Context with enhanced features
context: ({ req, res, connection }) => {
// HTTP Request Context
if (req) {
return {
req,
res,
user: req.user,
headers: req.headers,
correlationId: req.headers['x-correlation-id'],
};
}
// WebSocket Context (for subscriptions)
if (connection) {
return {
user: connection.context.user,
correlationId: connection.context.correlationId,
};
}
},
// Subscription Configuration
subscriptions: {
'graphql-ws': {
onConnect: (context) => {
const { connectionParams, extra } = context;
// Authentication for WebSocket connections
return this.authenticateWebSocketConnection(connectionParams, extra);
},
onDisconnect: (context) => {
console.log('WebSocket disconnected:', context);
},
},
},
// Error Handling
formatError: (error) => this.formatGraphQLError(error),
// Performance Monitoring
plugins: [
{
requestDidStart() {
return {
willSendResponse(requestContext) {
const { request, response } = requestContext;
// Log slow queries
if (requestContext.metrics?.executionTime > 1000) {
console.warn('Slow GraphQL Query:', {
query: request.query,
variables: request.variables,
executionTime: requestContext.metrics.executionTime,
});
}
// Add performance headers
response.http.setHeader('X-Execution-Time',
requestContext.metrics?.executionTime || 0);
},
};
},
},
],
// Cache Control
cache: isProduction ? 'bounded' : false,
// Field Resolution
fieldResolverEnhancers: ['interceptors', 'guards', 'filters'],
};
}
private async authenticateWebSocketConnection(
connectionParams: any,
extra: any
): Promise<any> {
try {
const token = connectionParams?.authorization?.replace('Bearer ', '');
if (!token) {
throw new Error('No authorization token provided');
}
// Validate JWT token
const user = await this.validateJwtToken(token);
return {
user,
correlationId: connectionParams?.correlationId || `ws_${Date.now()}`,
};
} catch (error) {
throw new Error('Authentication failed');
}
}
private formatGraphQLError(error: any): any {
const isProduction = this.configService.get('NODE_ENV') === 'production';
// Log all errors
console.error('GraphQL Error:', {
message: error.message,
path: error.path,
source: error.source?.body,
positions: error.positions,
originalError: error.originalError,
});
// Production error formatting
if (isProduction) {
// Don't expose sensitive information
return {
message: error.message,
code: error.extensions?.code || 'INTERNAL_ERROR',
path: error.path,
timestamp: new Date().toISOString(),
};
}
// Development error formatting
return {
message: error.message,
code: error.extensions?.code,
path: error.path,
locations: error.locations,
stack: error.stack,
extensions: error.extensions,
};
}
private async validateJwtToken(token: string): Promise<any> {
// JWT validation logic
// This would typically use your authentication service
return { id: 'user123', email: 'user@example.com' };
}
}
// Usage with ConfigService
@Module({
imports: [
ConfigModule.forRoot(),
GraphQLModule.forRootAsync({
driver: ApolloDriver,
useClass: GraphQLConfigService,
}),
],
})
export class AppModule {}import { Module } from '@nestjs/common';
import { GraphQLFederationModule } from '@nestjs/graphql';
import { ApolloFederationDriver, ApolloFederationDriverConfig } from '@nestjs/apollo';
// User Service (Subgraph)
@Module({
imports: [
GraphQLFederationModule.forRoot<ApolloFederationDriverConfig>({
driver: ApolloFederationDriver,
autoSchemaFile: join(process.cwd(), 'src/user-schema.gql'),
federationMetadata: true,
context: ({ req }) => ({
user: req.user,
headers: req.headers,
}),
// Subgraph specific configuration
buildSchemaOptions: {
orphanedTypes: [], // Include types that aren't directly referenced
},
}),
UserModule,
],
})
export class UserServiceModule {}
// API Gateway (Supergraph)
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloGatewayDriver, ApolloGatewayDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloGatewayDriverConfig>({
driver: ApolloGatewayDriver,
gateway: {
buildService: ({ url }) => new RemoteGraphQLDataSource({
url,
willSendRequest({ request, context }) {
// Forward authentication headers
if (context.user) {
request.http.headers.set('user-id', context.user.id);
request.http.headers.set('user-roles', JSON.stringify(context.user.roles));
}
// Forward correlation ID
if (context.correlationId) {
request.http.headers.set('x-correlation-id', context.correlationId);
}
},
}),
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://localhost:3001/graphql' },
{ name: 'products', url: 'http://localhost:3002/graphql' },
{ name: 'orders', url: 'http://localhost:3003/graphql' },
],
// Polling interval for schema updates
pollIntervalInMs: 10000,
}),
},
context: ({ req }) => ({
user: req.user,
correlationId: req.headers['x-correlation-id'],
}),
// Gateway-specific plugins
plugins: [
{
requestDidStart() {
return {
willSendSubgraphRequest(requestContext) {
const { request, subgraphName } = requestContext;
console.log(`Sending request to ${subgraphName}:`, {
query: request.query,
variables: request.variables,
});
},
};
},
},
],
}),
],
})
export class GatewayModule {}Resolver sind das Herzstück von GraphQL und definieren, wie Felder aufgelöst werden. NestJS bietet typisierte Resolver mit Decorators.
import { ObjectType, Field, ID, Int, Float } from '@nestjs/graphql';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm';
// User Entity
@Entity()
@ObjectType()
export class User {
@Field(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ unique: true })
email: string;
@Field()
@Column()
name: string;
@Column() // Nicht als GraphQL Field exposed (Password)
password: string;
@Field(() => UserRole)
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
role: UserRole;
@Field(() => UserStatus)
@Column({ type: 'enum', enum: UserStatus, default: UserStatus.ACTIVE })
status: UserStatus;
@Field(() => Date)
@CreateDateColumn()
createdAt: Date;
@Field(() => Date)
@UpdateDateColumn()
updatedAt: Date;
@Field(() => Date, { nullable: true })
@Column({ nullable: true })
lastLoginAt?: Date;
// Relations
@Field(() => [Post])
@OneToMany(() => Post, post => post.author, { lazy: true })
posts: Post[];
@Field(() => UserProfile, { nullable: true })
@OneToOne(() => UserProfile, profile => profile.user, { lazy: true })
profile?: UserProfile;
@Field(() => [Order])
@OneToMany(() => Order, order => order.user, { lazy: true })
orders: Order[];
// Virtual Fields (computed properties)
@Field(() => Int)
get postCount(): number {
return this.posts?.length || 0;
}
@Field(() => Boolean)
get isActive(): boolean {
return this.status === UserStatus.ACTIVE;
}
@Field(() => String, { nullable: true })
get displayName(): string {
return this.profile?.displayName || this.name;
}
}
// Enums
import { registerEnumType } from '@nestjs/graphql';
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
MODERATOR = 'moderator',
}
export enum UserStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
}
registerEnumType(UserRole, {
name: 'UserRole',
description: 'User role in the system',
});
registerEnumType(UserStatus, {
name: 'UserStatus',
description: 'User account status',
});
// Post Entity
@Entity()
@ObjectType()
export class Post {
@Field(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
title: string;
@Field()
@Column('text')
content: string;
@Field(() => PostStatus)
@Column({ type: 'enum', enum: PostStatus, default: PostStatus.DRAFT })
status: PostStatus;
@Field(() => [String])
@Column('text', { array: true, default: [] })
tags: string[];
@Field(() => Int)
@Column({ default: 0 })
viewCount: number;
@Field(() => Float)
@Column({ default: 0 })
rating: number;
@Field(() => Date)
@CreateDateColumn()
createdAt: Date;
@Field(() => Date)
@UpdateDateColumn()
updatedAt: Date;
@Field(() => Date, { nullable: true })
@Column({ nullable: true })
publishedAt?: Date;
// Relations
@Field(() => User)
@ManyToOne(() => User, user => user.posts)
author: User;
@Column()
authorId: string;
@Field(() => [Comment])
@OneToMany(() => Comment, comment => comment.post, { lazy: true })
comments: Comment[];
@Field(() => [Category])
@ManyToMany(() => Category, category => category.posts)
@JoinTable()
categories: Category[];
// Virtual fields
@Field(() => Int)
get commentCount(): number {
return this.comments?.length || 0;
}
@Field(() => Boolean)
get isPublished(): boolean {
return this.status === PostStatus.PUBLISHED && this.publishedAt !== null;
}
}
export enum PostStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
registerEnumType(PostStatus, {
name: 'PostStatus',
description: 'Post publication status',
});import { InputType, Field, ID, Int, PartialType } from '@nestjs/graphql';
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum, IsArray } from 'class-validator';
// Create User Input
@InputType()
export class CreateUserInput {
@Field()
@IsEmail()
email: string;
@Field()
@IsString()
@MinLength(2)
@MaxLength(50)
name: string;
@Field()
@IsString()
@MinLength(8)
password: string;
@Field(() => UserRole, { defaultValue: UserRole.USER })
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
}
// Update User Input
@InputType()
export class UpdateUserInput extends PartialType(CreateUserInput) {
@Field(() => ID)
id: string;
@Field(() => UserStatus, { nullable: true })
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
}
// Post Input
@InputType()
export class CreatePostInput {
@Field()
@IsString()
@MinLength(5)
@MaxLength(200)
title: string;
@Field()
@IsString()
@MinLength(10)
content: string;
@Field(() => [String], { defaultValue: [] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@Field(() => [ID], { defaultValue: [] })
@IsOptional()
@IsArray()
categoryIds?: string[];
@Field(() => PostStatus, { defaultValue: PostStatus.DRAFT })
@IsOptional()
@IsEnum(PostStatus)
status?: PostStatus;
}
@InputType()
export class UpdatePostInput extends PartialType(CreatePostInput) {
@Field(() => ID)
id: string;
}
// Search and Filter Inputs
@InputType()
export class UserFilterInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
search?: string;
@Field(() => UserRole, { nullable: true })
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@Field(() => UserStatus, { nullable: true })
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
@Field(() => Date, { nullable: true })
@IsOptional()
createdAfter?: Date;
@Field(() => Date, { nullable: true })
@IsOptional()
createdBefore?: Date;
}
@InputType()
export class PostFilterInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
search?: string;
@Field(() => PostStatus, { nullable: true })
@IsOptional()
@IsEnum(PostStatus)
status?: PostStatus;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
tags?: string[];
@Field(() => ID, { nullable: true })
@IsOptional()
authorId?: string;
@Field(() => [ID], { nullable: true })
@IsOptional()
@IsArray()
categoryIds?: string[];
@Field(() => Float, { nullable: true })
@IsOptional()
minRating?: number;
}
// Pagination Input
@InputType()
export class PaginationInput {
@Field(() => Int, { defaultValue: 1 })
@IsOptional()
@Min(1)
page?: number = 1;
@Field(() => Int, { defaultValue: 20 })
@IsOptional()
@Min(1)
@Max(100)
limit?: number = 20;
@Field({ nullable: true })
@IsOptional()
@IsString()
cursor?: string;
}
// Sort Input
@InputType()
export class SortInput {
@Field()
@IsString()
field: string;
@Field(() => SortDirection, { defaultValue: SortDirection.ASC })
@IsOptional()
@IsEnum(SortDirection)
direction?: SortDirection = SortDirection.ASC;
}
export enum SortDirection {
ASC = 'ASC',
DESC = 'DESC',
}
registerEnumType(SortDirection, {
name: 'SortDirection',
description: 'Sort direction for queries',
});
// Complex Query Input
@InputType()
export class PostQueryInput {
@Field(() => PostFilterInput, { nullable: true })
@IsOptional()
filter?: PostFilterInput;
@Field(() => PaginationInput, { nullable: true })
@IsOptional()
pagination?: PaginationInput;
@Field(() => [SortInput], { nullable: true })
@IsOptional()
@IsArray()
sort?: SortInput[];
}import { Resolver, Query, Mutation, Args, Context, ResolveField, Parent, Subscription } from '@nestjs/graphql';
import { UseGuards, UseInterceptors, UsePipes } from '@nestjs/common';
import { PubSub } from 'graphql-subscriptions';
@Resolver(() => User)
export class UserResolver {
private pubSub = new PubSub();
constructor(
private userService: UserService,
private postService: PostService,
private dataLoaderService: DataLoaderService,
) {}
// Queries
@Query(() => [User])
@UseGuards(GraphQLAuthGuard)
@UseInterceptors(LoggingInterceptor)
async users(
@Args('filter', { nullable: true }) filter?: UserFilterInput,
@Args('pagination', { nullable: true }) pagination?: PaginationInput,
@Args('sort', { nullable: true }) sort?: SortInput[],
@Context() context?: any,
): Promise<User[]> {
const queryOptions = {
filter: filter || {},
pagination: pagination || { page: 1, limit: 20 },
sort: sort || [{ field: 'createdAt', direction: SortDirection.DESC }],
};
return await this.userService.findMany(queryOptions);
}
@Query(() => User, { nullable: true })
@UseGuards(GraphQLAuthGuard)
async user(
@Args('id', { type: () => ID }) id: string,
@Context() context?: any,
): Promise<User | null> {
const user = await this.userService.findById(id);
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// Authorization check
if (!this.canAccessUser(context.user, user)) {
throw new ForbiddenException('Access denied');
}
return user;
}
@Query(() => User)
@UseGuards(GraphQLAuthGuard)
async me(@Context() context: any): Promise<User> {
return await this.userService.findById(context.user.id);
}
// Mutations
@Mutation(() => User)
@UseGuards(GraphQLAuthGuard, AdminGuard)
@UsePipes(ValidationPipe)
async createUser(
@Args('input') input: CreateUserInput,
@Context() context: any,
): Promise<User> {
const user = await this.userService.create(input);
// Publish subscription event
this.pubSub.publish('userCreated', { userCreated: user });
return user;
}
@Mutation(() => User)
@UseGuards(GraphQLAuthGuard)
async updateUser(
@Args('input') input: UpdateUserInput,
@Context() context: any,
): Promise<User> {
// Authorization check
if (!this.canModifyUser(context.user, input.id)) {
throw new ForbiddenException('Access denied');
}
const user = await this.userService.update(input.id, input);
// Publish subscription event
this.pubSub.publish('userUpdated', { userUpdated: user });
return user;
}
@Mutation(() => Boolean)
@UseGuards(GraphQLAuthGuard, AdminGuard)
async deleteUser(
@Args('id', { type: () => ID }) id: string,
@Context() context: any,
): Promise<boolean> {
const success = await this.userService.delete(id);
if (success) {
this.pubSub.publish('userDeleted', { userDeleted: { id } });
}
return success;
}
// Field Resolvers
@ResolveField(() => [Post])
async posts(
@Parent() user: User,
@Args('filter', { nullable: true }) filter?: PostFilterInput,
@Args('pagination', { nullable: true }) pagination?: PaginationInput,
): Promise<Post[]> {
// Use DataLoader to prevent N+1 queries
return await this.dataLoaderService.postsByUserIdLoader.load({
userId: user.id,
filter,
pagination,
});
}
@ResolveField(() => UserProfile, { nullable: true })
async profile(@Parent() user: User): Promise<UserProfile | null> {
return await this.dataLoaderService.userProfileLoader.load(user.id);
}
@ResolveField(() => Int)
async postCount(@Parent() user: User): Promise<number> {
return await this.dataLoaderService.userPostCountLoader.load(user.id);
}
@ResolveField(() => [User])
async followers(
@Parent() user: User,
@Args('pagination', { nullable: true }) pagination?: PaginationInput,
): Promise<User[]> {
return await this.userService.getFollowers(user.id, pagination);
}
@ResolveField(() => [User])
async following(
@Parent() user: User,
@Args('pagination', { nullable: true }) pagination?: PaginationInput,
): Promise<User[]> {
return await this.userService.getFollowing(user.id, pagination);
}
// Subscriptions
@Subscription(() => User, {
filter: (payload, variables, context) => {
// Only send updates for users the subscriber can access
return context.user && context.user.role === 'admin';
},
})
@UseGuards(GraphQLAuthGuard)
userCreated() {
return this.pubSub.asyncIterator('userCreated');
}
@Subscription(() => User, {
filter: (payload, variables, context) => {
// Users can subscribe to their own updates
return payload.userUpdated.id === context.user.id || context.user.role === 'admin';
},
})
@UseGuards(GraphQLAuthGuard)
userUpdated() {
return this.pubSub.asyncIterator('userUpdated');
}
// Helper methods
private canAccessUser(currentUser: any, targetUser: User): boolean {
// Admins can access all users
if (currentUser.role === 'admin') {
return true;
}
// Users can access their own profile
if (currentUser.id === targetUser.id) {
return true;
}
// Users can access public profiles
return targetUser.status === UserStatus.ACTIVE;
}
private canModifyUser(currentUser: any, targetUserId: string): boolean {
// Admins can modify all users
if (currentUser.role === 'admin') {
return true;
}
// Users can only modify their own profile
return currentUser.id === targetUserId;
}
}
// Post Resolver
@Resolver(() => Post)
export class PostResolver {
private pubSub = new PubSub();
constructor(
private postService: PostService,
private userService: UserService,
private dataLoaderService: DataLoaderService,
) {}
@Query(() => [Post])
async posts(
@Args('query', { nullable: true }) query?: PostQueryInput,
@Context() context?: any,
): Promise<Post[]> {
return await this.postService.findMany(query || {});
}
@Query(() => Post, { nullable: true })
async post(
@Args('id', { type: () => ID }) id: string,
@Context() context?: any,
): Promise<Post | null> {
const post = await this.postService.findById(id);
if (!post) {
return null;
}
// Increment view count
await this.postService.incrementViewCount(id);
return post;
}
@Mutation(() => Post)
@UseGuards(GraphQLAuthGuard)
async createPost(
@Args('input') input: CreatePostInput,
@Context() context: any,
): Promise<Post> {
const post = await this.postService.create({
...input,
authorId: context.user.id,
});
this.pubSub.publish('postCreated', { postCreated: post });
return post;
}
@Mutation(() => Post)
@UseGuards(GraphQLAuthGuard)
async updatePost(
@Args('input') input: UpdatePostInput,
@Context() context: any,
): Promise<Post> {
const post = await this.postService.findById(input.id);
if (!post) {
throw new NotFoundException(`Post with ID ${input.id} not found`);
}
// Authorization check
if (!this.canModifyPost(context.user, post)) {
throw new ForbiddenException('Access denied');
}
const updatedPost = await this.postService.update(input.id, input);
this.pubSub.publish('postUpdated', { postUpdated: updatedPost });
return updatedPost;
}
@Mutation(() => Boolean)
@UseGuards(GraphQLAuthGuard)
async deletePost(
@Args('id', { type: () => ID }) id: string,
@Context() context: any,
): Promise<boolean> {
const post = await this.postService.findById(id);
if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
if (!this.canModifyPost(context.user, post)) {
throw new ForbiddenException('Access denied');
}
const success = await this.postService.delete(id);
if (success) {
this.pubSub.publish('postDeleted', { postDeleted: { id } });
}
return success;
}
// Field Resolvers
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User> {
return await this.dataLoaderService.userLoader.load(post.authorId);
}
@ResolveField(() => [Comment])
async comments(
@Parent() post: Post,
@Args('pagination', { nullable: true }) pagination?: PaginationInput,
): Promise<Comment[]> {
return await this.dataLoaderService.commentsByPostIdLoader.load({
postId: post.id,
pagination,
});
}
@ResolveField(() => [Category])
async categories(@Parent() post: Post): Promise<Category[]> {
return await this.dataLoaderService.categoriesByPostIdLoader.load(post.id);
}
private canModifyPost(user: any, post: Post): boolean {
return user.role === 'admin' || user.id === post.authorId;
}
}NestJS unterstützt beide Ansätze für GraphQL-Schema-Definition mit unterschiedlichen Vor- und Nachteilen.
Der Code First Ansatz generiert das GraphQL-Schema automatisch aus TypeScript-Klassen und Decorators.
// Code First - Entity Definition
import { ObjectType, Field, ID, Int, registerEnumType } from '@nestjs/graphql';
@ObjectType()
export class Book {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field()
description: string;
@Field(() => Int)
pages: number;
@Field(() => BookStatus)
status: BookStatus;
@Field(() => [String])
genres: string[];
@Field(() => Author)
author: Author;
@Field(() => [Review])
reviews: Review[];
@Field(() => Float, { nullable: true })
averageRating?: number;
@Field(() => Date)
publishedAt: Date;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@ObjectType()
export class Author {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field({ nullable: true })
biography?: string;
@Field(() => Date, { nullable: true })
birthDate?: Date;
@Field(() => [Book])
books: Book[];
@Field(() => [Award])
awards: Award[];
}
@ObjectType()
export class Review {
@Field(() => ID)
id: string;
@Field(() => Int, { description: 'Rating from 1 to 5' })
rating: number;
@Field()
comment: string;
@Field(() => User)
reviewer: User;
@Field(() => Book)
book: Book;
@Field(() => Date)
createdAt: Date;
}
export enum BookStatus {
DRAFT = 'draft',
PUBLISHED = 'published',
OUT_OF_PRINT = 'out_of_print',
}
registerEnumType(BookStatus, {
name: 'BookStatus',
description: 'Book publication status',
});
// Input Types
@InputType()
export class CreateBookInput {
@Field()
@IsString()
@MinLength(1)
@MaxLength(200)
title: string;
@Field()
@IsString()
@MinLength(10)
description: string;
@Field(() => Int)
@IsNumber()
@Min(1)
pages: number;
@Field(() => [String])
@IsArray()
@IsString({ each: true })
genres: string[];
@Field(() => ID)
@IsString()
authorId: string;
@Field(() => Date, { nullable: true })
@IsOptional()
@IsDate()
publishedAt?: Date;
}
// Custom Scalars
import { Scalar, CustomScalar } from '@nestjs/graphql';
import { Kind, ValueNode } from 'graphql';
@Scalar('DateTime', () => Date)
export class DateTimeScalar implements CustomScalar<number, Date> {
description = 'Date custom scalar type';
parseValue(value: number): Date {
return new Date(value); // Value from the client
}
serialize(value: Date): number {
return value.getTime(); // Value sent to the client
}
parseLiteral(ast: ValueNode): Date {
if (ast.kind === Kind.INT) {
return new Date(ast.value);
}
return null;
}
}
// Union Types
import { createUnionType } from '@nestjs/graphql';
export const SearchResult = createUnionType({
name: 'SearchResult',
types: () => [Book, Author] as const,
resolveType(value) {
if (value.title) {
return Book;
}
if (value.name) {
return Author;
}
return null;
},
});
// Interface Types
import { InterfaceType } from '@nestjs/graphql';
@InterfaceType()
export abstract class Node {
@Field(() => ID)
id: string;
}
@ObjectType({ implements: () => [Node] })
export class BookNode extends Node {
@Field()
title: string;
// ... other book fields
}
// Advanced Field Configuration
@ObjectType()
export class AdvancedBook {
@Field(() => ID)
id: string;
@Field()
title: string;
// Computed field with custom complexity
@Field(() => Float, {
nullable: true,
description: 'Average rating calculated from all reviews',
complexity: 5, // Query complexity points
})
averageRating?: number;
// Field with custom middleware
@Field(() => String, {
middleware: [LoggingFieldMiddleware],
})
sensitiveData: string;
// Deprecated field
@Field({
deprecationReason: 'Use newField instead',
})
oldField: string;
@Field()
newField: string;
}Der Schema First Ansatz definiert das GraphQL-Schema in
.graphql Dateien und generiert TypeScript-Interfaces.
# schema.graphql
type Book {
id: ID!
title: String!
description: String!
pages: Int!
status: BookStatus!
genres: [String!]!
author: Author!
reviews: [Review!]!
averageRating: Float
publishedAt: DateTime!
createdAt: DateTime!
updatedAt: DateTime!
}
type Author {
id: ID!
name: String!
biography: String
birthDate: DateTime
books: [Book!]!
awards: [Award!]!
}
type Review {
id: ID!
rating: Int!
comment: String!
reviewer: User!
book: Book!
createdAt: DateTime!
}
enum BookStatus {
DRAFT
PUBLISHED
OUT_OF_PRINT
}
input CreateBookInput {
title: String!
description: String!
pages: Int!
genres: [String!]!
authorId: ID!
publishedAt: DateTime
}
input BookFilterInput {
search: String
status: BookStatus
genres: [String!]
authorId: ID
minPages: Int
maxPages: Int
minRating: Float
publishedAfter: DateTime
publishedBefore: DateTime
}
type Query {
books(filter: BookFilterInput, pagination: PaginationInput): [Book!]!
book(id: ID!): Book
authors(filter: AuthorFilterInput): [Author!]!
author(id: ID!): Author
searchBooks(query: String!): [SearchResult!]!
}
type Mutation {
createBook(input: CreateBookInput!): Book!
updateBook(id: ID!, input: UpdateBookInput!): Book!
deleteBook(id: ID!): Boolean!
createAuthor(input: CreateAuthorInput!): Author!
updateAuthor(id: ID!, input: UpdateAuthorInput!): Author!
createReview(input: CreateReviewInput!): Review!
updateReview(id: ID!, input: UpdateReviewInput!): Review!
deleteReview(id: ID!): Boolean!
}
type Subscription {
bookCreated: Book!
bookUpdated: Book!
bookDeleted: BookDeletedPayload!
reviewAdded(bookId: ID!): Review!
}
# Custom scalars
scalar DateTime
scalar JSON
# Union types
union SearchResult = Book | Author
# Interface types
interface Node {
id: ID!
}
# Directives
directive @auth(requires: UserRole = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: Int!) on FIELD_DEFINITION// Schema First Resolver Implementation
import { Resolver, Query, Mutation, Args, Context, ResolveField, Parent } from '@nestjs/graphql';
// Generated types from schema.graphql
export interface Book {
id: string;
title: string;
description: string;
pages: number;
status: BookStatus;
genres: string[];
authorId: string;
publishedAt: Date;
createdAt: Date;
updatedAt: Date;
}
export interface CreateBookInput {
title: string;
description: string;
pages: number;
genres: string[];
authorId: string;
publishedAt?: Date;
}
@Resolver('Book')
export class BookResolver {
constructor(
private bookService: BookService,
private authorService: AuthorService,
private reviewService: ReviewService,
) {}
@Query('books')
async getBooks(
@Args('filter') filter?: BookFilterInput,
@Args('pagination') pagination?: PaginationInput,
): Promise<Book[]> {
return await this.bookService.findMany({ filter, pagination });
}
@Query('book')
async getBook(@Args('id') id: string): Promise<Book | null> {
return await this.bookService.findById(id);
}
@Mutation('createBook')
async createBook(
@Args('input') input: CreateBookInput,
@Context() context: any,
): Promise<Book> {
return await this.bookService.create(input);
}
@ResolveField('author')
async getAuthor(@Parent() book: Book): Promise<Author> {
return await this.authorService.findById(book.authorId);
}
@ResolveField('reviews')
async getReviews(@Parent() book: Book): Promise<Review[]> {
return await this.reviewService.findByBookId(book.id);
}
@ResolveField('averageRating')
async getAverageRating(@Parent() book: Book): Promise<number | null> {
return await this.reviewService.calculateAverageRating(book.id);
}
}
// Code Generation Configuration
// graphql-code-generator.yml
overwrite: true
schema: "src/**/*.graphql"
generates:
src/graphql/generated.ts:
plugins:
- "typescript"
- "typescript-resolvers"
config:
contextType: "../types#GraphQLContext"
mappers:
Book: "../models#BookModel"
Author: "../models#AuthorModel"
Review: "../models#ReviewModel"// Combining both approaches
@Module({
imports: [
// Schema First für externe APIs
GraphQLModule.forFeature({
typePaths: ['./**/*.external.graphql'],
resolvers: [ExternalApiResolver],
}),
// Code First für interne Entities
GraphQLModule.forFeature({
autoSchemaFile: 'src/internal-schema.gql',
resolvers: [UserResolver, PostResolver],
}),
],
})
export class HybridGraphQLModule {}
// Schema Stitching für mehrere Schemas
import { mergeSchemas } from '@graphql-tools/schema';
export const createStitchedSchema = () => {
return mergeSchemas({
schemas: [
internalSchema,
externalApiSchema,
],
resolvers: {
// Cross-schema resolvers
User: {
externalData: {
fragment: 'fragment UserFragment on User { id }',
resolve(user, args, context, info) {
return context.externalApiLoader.load(user.id);
},
},
},
},
});
};DataLoader löst das N+1-Query-Problem durch Batch-Loading und Caching von Datenbankzugriffen.
import DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
@Injectable({ scope: Scope.REQUEST })
export class DataLoaderService {
constructor(
@InjectRepository(User) private userRepository: Repository<User>,
@InjectRepository(Post) private postRepository: Repository<Post>,
@InjectRepository(Comment) private commentRepository: Repository<Comment>,
@InjectRepository(Category) private categoryRepository: Repository<Category>,
@InjectRepository(UserProfile) private userProfileRepository: Repository<UserProfile>,
) {}
// User Loader
readonly userLoader = new DataLoader<string, User>(
async (userIds: readonly string[]) => {
const users = await this.userRepository.find({
where: { id: In(userIds as string[]) },
});
// Return users in the same order as requested IDs
return userIds.map(id => users.find(user => user.id === id) || null);
},
{
cache: true,
maxBatchSize: 100,
batchScheduleFn: callback => setTimeout(callback, 1), // Batch within 1ms
}
);
// Posts by User ID Loader
readonly postsByUserIdLoader = new DataLoader<
{ userId: string; filter?: any; pagination?: any },
Post[]
>(
async (keys) => {
const results = await Promise.all(
keys.map(async ({ userId, filter, pagination }) => {
const queryBuilder = this.postRepository
.createQueryBuilder('post')
.where('post.authorId = :userId', { userId });
// Apply filters
if (filter?.status) {
queryBuilder.andWhere('post.status = :status', { status: filter.status });
}
if (filter?.search) {
queryBuilder.andWhere(
'(post.title ILIKE :search OR post.content ILIKE :search)',
{ search: `%${filter.search}%` }
);
}
// Apply pagination
if (pagination?.limit) {
queryBuilder.limit(pagination.limit);
}
if (pagination?.page && pagination?.limit) {
queryBuilder.offset((pagination.page - 1) * pagination.limit);
}
return await queryBuilder.getMany();
})
);
return results;
},
{
cache: false, // Don't cache filtered results
maxBatchSize: 50,
}
);
// Comments by Post ID Loader
readonly commentsByPostIdLoader = new DataLoader<
{ postId: string; pagination?: any },
Comment[]
>(
async (keys) => {
// Group by postId for efficient querying
const postIds = [...new Set(keys.map(k => k.postId))];
const comments = await this.commentRepository.find({
where: { postId: In(postIds) },
order: { createdAt: 'DESC' },
relations: ['author'],
});
// Group comments by postId
const commentsByPostId = new Map<string, Comment[]>();
comments.forEach(comment => {
if (!commentsByPostId.has(comment.postId)) {
commentsByPostId.set(comment.postId, []);
}
commentsByPostId.get(comment.postId)!.push(comment);
});
// Return comments for each requested key with pagination
return keys.map(({ postId, pagination }) => {
const postComments = commentsByPostId.get(postId) || [];
if (pagination?.limit) {
const offset = pagination.page ? (pagination.page - 1) * pagination.limit : 0;
return postComments.slice(offset, offset + pagination.limit);
}
return postComments;
});
},
{
cache: true,
maxBatchSize: 100,
}
);
// User Profile Loader
readonly userProfileLoader = new DataLoader<string, UserProfile | null>(
async (userIds: readonly string[]) => {
const profiles = await this.userProfileRepository.find({
where: { userId: In(userIds as string[]) },
});
return userIds.map(userId =>
profiles.find(profile => profile.userId === userId) || null
);
},
{
cache: true,
maxBatchSize: 100,
}
);
// Categories by Post ID Loader (Many-to-Many)
readonly categoriesByPostIdLoader = new DataLoader<string, Category[]>(
async (postIds: readonly string[]) => {
const query = `
SELECT p.id as "postId", c.*
FROM post p
JOIN post_categories pc ON p.id = pc."postId"
JOIN category c ON pc."categoryId" = c.id
WHERE p.id = ANY($1)
`;
const results = await this.categoryRepository.query(query, [postIds as string[]]);
// Group categories by postId
const categoriesByPostId = new Map<string, Category[]>();
results.forEach(row => {
const { postId, ...category } = row;
if (!categoriesByPostId.has(postId)) {
categoriesByPostId.set(postId, []);
}
categoriesByPostId.get(postId)!.push(category);
});
return postIds.map(postId => categoriesByPostId.get(postId) || []);
},
{
cache: true,
maxBatchSize: 100,
}
);
// Count Loaders
readonly userPostCountLoader = new DataLoader<string, number>(
async (userIds: readonly string[]) => {
const counts = await this.postRepository
.createQueryBuilder('post')
.select('post.authorId', 'authorId')
.addSelect('COUNT(*)', 'count')
.where('post.authorId IN (:...userIds)', { userIds: userIds as string[] })
.groupBy('post.authorId')
.getRawMany();
const countMap = new Map(counts.map(c => [c.authorId, parseInt(c.count)]));
return userIds.map(userId => countMap.get(userId) || 0);
},
{
cache: true,
maxBatchSize: 100,
}
);
readonly postCommentCountLoader = new DataLoader<string, number>(
async (postIds: readonly string[]) => {
const counts = await this.commentRepository
.createQueryBuilder('comment')
.select('comment.postId', 'postId')
.addSelect('COUNT(*)', 'count')
.where('comment.postId IN (:...postIds)', { postIds: postIds as string[] })
.groupBy('comment.postId')
.getRawMany();
const countMap = new Map(counts.map(c => [c.postId, parseInt(c.count)]));
return postIds.map(postId => countMap.get(postId) || 0);
},
{
cache: true,
maxBatchSize: 100,
}
);
// Generic Loader Factory
createLoader<K, V>(
batchLoadFn: (keys: readonly K[]) => Promise<V[]>,
options?: DataLoader.Options<K, V>
): DataLoader<K, V> {
return new DataLoader(batchLoadFn, {
cache: true,
maxBatchSize: 100,
...options,
});
}
// Priming functions (preload known data)
primeUser(user: User): void {
this.userLoader.prime(user.id, user);
}
primeUserProfile(profile: UserProfile): void {
this.userProfileLoader.prime(profile.userId, profile);
}
// Cache clearing functions
clearUserCache(userId: string): void {
this.userLoader.clear(userId);
this.userProfileLoader.clear(userId);
this.postsByUserIdLoader.clearAll(); // Clear related caches
this.userPostCountLoader.clear(userId);
}
clearPostCache(postId: string): void {
this.commentsByPostIdLoader.clearAll(); // Clear filtered caches
this.postCommentCountLoader.clear(postId);
this.categoriesByPostIdLoader.clear(postId);
}
// Clear all caches (useful for testing)
clearAllCaches(): void {
this.userLoader.clearAll();
this.postsByUserIdLoader.clearAll();
this.commentsByPostIdLoader.clearAll();
this.userProfileLoader.clearAll();
this.categoriesByPostIdLoader.clearAll();
this.userPostCountLoader.clearAll();
this.postCommentCountLoader.clearAll();
}
}// Conditional DataLoader
@Injectable({ scope: Scope.REQUEST })
export class ConditionalDataLoaderService {
constructor(
private dataLoaderService: DataLoaderService,
@Inject(REQUEST) private request: any,
) {}
// Context-aware loader
readonly contextualUserLoader = new DataLoader<string, User>(
async (userIds: readonly string[]) => {
const currentUser = this.request.user;
// Admins can see all users
if (currentUser?.role === 'admin') {
return this.dataLoaderService.userLoader.loadMany(userIds as string[]);
}
// Regular users can only see active users
const users = await this.userRepository.find({
where: {
id: In(userIds as string[]),
status: UserStatus.ACTIVE,
},
});
return userIds.map(id =>
users.find(user => user.id === id) || new Error('User not found or not accessible')
);
}
);
// Cached computed fields
readonly userFullNameLoader = new DataLoader<string, string>(
async (userIds: readonly string[]) => {
const users = await this.dataLoaderService.userLoader.loadMany(userIds as string[]);
const profiles = await this.dataLoaderService.userProfileLoader.loadMany(userIds as string[]);
return userIds.map((userId, index) => {
const user = users[index] as User;
const profile = profiles[index] as UserProfile;
if (profile?.displayName) {
return profile.displayName;
}
return user?.name || 'Unknown User';
});
},
{
cache: true,
maxBatchSize: 100,
}
);
// Aggregation loader
readonly userStatsLoader = new DataLoader<string, UserStats>(
async (userIds: readonly string[]) => {
const [postCounts, commentCounts, followerCounts] = await Promise.all([
this.dataLoaderService.userPostCountLoader.loadMany(userIds as string[]),
this.getUserCommentCounts(userIds as string[]),
this.getUserFollowerCounts(userIds as string[]),
]);
return userIds.map((userId, index) => ({
userId,
postCount: postCounts[index] as number || 0,
commentCount: commentCounts[index] || 0,
followerCount: followerCounts[index] || 0,
}));
}
);
private async getUserCommentCounts(userIds: string[]): Promise<number[]> {
const counts = await this.commentRepository
.createQueryBuilder('comment')
.select('comment.authorId', 'authorId')
.addSelect('COUNT(*)', 'count')
.where('comment.authorId IN (:...userIds)', { userIds })
.groupBy('comment.authorId')
.getRawMany();
const countMap = new Map(counts.map(c => [c.authorId, parseInt(c.count)]));
return userIds.map(userId => countMap.get(userId) || 0);
}
private async getUserFollowerCounts(userIds: string[]): Promise<number[]> {
// Implementation for follower counts
return userIds.map(() => 0); // Placeholder
}
}
interface UserStats {
userId: string;
postCount: number;
commentCount: number;
followerCount: number;
}
// DataLoader with Redis Caching
@Injectable({ scope: Scope.REQUEST })
export class RedisDataLoaderService {
constructor(
@Inject('REDIS_CLIENT') private redis: Redis,
private dataLoaderService: DataLoaderService,
) {}
readonly persistentUserLoader = new DataLoader<string, User>(
async (userIds: readonly string[]) => {
const cacheKeys = userIds.map(id => `user:${id}`);
const cached = await this.redis.mget(...cacheKeys);
const uncachedIds: string[] = [];
const results: (User | null)[] = [];
userIds.forEach((id, index) => {
if (cached[index]) {
results[index] = JSON.parse(cached[index]);
} else {
uncachedIds.push(id);
results[index] = null;
}
});
// Fetch uncached users
if (uncachedIds.length > 0) {
const uncachedUsers = await this.dataLoaderService.userLoader.loadMany(uncachedIds);
// Cache the results
const pipeline = this.redis.pipeline();
uncachedUsers.forEach((user, index) => {
if (user && !(user instanceof Error)) {
const cacheKey = `user:${uncachedIds[index]}`;
pipeline.setex(cacheKey, 300, JSON.stringify(user)); // 5 minutes cache
}
});
await pipeline.exec();
// Merge results
let uncachedIndex = 0;
results.forEach((result, index) => {
if (result === null) {
results[index] = uncachedUsers[uncachedIndex] as User;
uncachedIndex++;
}
});
}
return results;
},
{
cache: false, // Don't use in-memory cache since we have Redis
maxBatchSize: 100,
}
);
}GraphQL Subscriptions ermöglichen Real-time Updates über WebSocket-Verbindungen.
import { Resolver, Subscription, Mutation, Args, Context } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import { UseGuards } from '@nestjs/common';
@Resolver()
export class SubscriptionResolver {
constructor(private pubSub: PubSub) {}
// Basic Subscription
@Subscription(() => Post, {
name: 'postAdded',
})
@UseGuards(GraphQLAuthGuard)
postAdded() {
return this.pubSub.asyncIterator('postAdded');
}
// Filtered Subscription
@Subscription(() => Comment, {
name: 'commentAdded',
filter: (payload, variables, context) => {
// Only send comments for the specified post
return payload.commentAdded.postId === variables.postId;
},
})
@UseGuards(GraphQLAuthGuard)
commentAdded(@Args('postId') postId: string) {
return this.pubSub.asyncIterator('commentAdded');
}
// Conditional Subscription based on user role
@Subscription(() => User, {
name: 'userStatusChanged',
filter: (payload, variables, context) => {
// Only admins can subscribe to user status changes
return context.user?.role === 'admin';
},
})
@UseGuards(GraphQLAuthGuard, AdminGuard)
userStatusChanged() {
return this.pubSub.asyncIterator('userStatusChanged');
}
// Subscription with dynamic topic
@Subscription(() => Notification, {
name: 'notificationReceived',
resolve: (payload) => {
// Transform payload before sending to client
return {
...payload.notificationReceived,
isNew: true,
receivedAt: new Date(),
};
},
filter: (payload, variables, context) => {
// Users can only receive their own notifications
return payload.notificationReceived.userId === context.user.id;
},
})
@UseGuards(GraphQLAuthGuard)
notificationReceived() {
return this.pubSub.asyncIterator('notificationReceived');
}
// Triggering subscription events
@Mutation(() => Post)
@UseGuards(GraphQLAuthGuard)
async createPost(
@Args('input') input: CreatePostInput,
@Context() context: any,
): Promise<Post> {
const post = await this.postService.create({
...input,
authorId: context.user.id,
});
// Trigger subscription
await this.pubSub.publish('postAdded', { postAdded: post });
return post;
}
@Mutation(() => Comment)
@UseGuards(GraphQLAuthGuard)
async addComment(
@Args('input') input: CreateCommentInput,
@Context() context: any,
): Promise<Comment> {
const comment = await this.commentService.create({
...input,
authorId: context.user.id,
});
// Trigger subscription with post-specific event
await this.pubSub.publish('commentAdded', { commentAdded: comment });
// Also trigger general notification
await this.pubSub.publish('notificationReceived', {
notificationReceived: {
id: `notif_${Date.now()}`,
type: 'COMMENT_ADDED',
userId: comment.post.authorId, // Notify post author
message: `${context.user.name} commented on your post`,
data: { commentId: comment.id, postId: comment.postId },
},
});
return comment;
}
}// Redis PubSub for Scalable Subscriptions
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
@Injectable()
export class PubSubService {
private pubSub: RedisPubSub;
constructor() {
const options = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
retryDelayOnFailover: 100,
enableOfflineQueue: false,
lazyConnect: true,
};
this.pubSub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
}
async publish(triggerName: string, payload: any): Promise<void> {
await this.pubSub.publish(triggerName, payload);
}
asyncIterator<T>(triggers: string | string[]): AsyncIterator<T> {
return this.pubSub.asyncIterator<T>(triggers);
}
// Typed publish methods
async publishPostAdded(post: Post): Promise<void> {
await this.publish('postAdded', { postAdded: post });
}
async publishCommentAdded(comment: Comment): Promise<void> {
await this.publish('commentAdded', { commentAdded: comment });
}
async publishUserStatusChanged(user: User): Promise<void> {
await this.publish('userStatusChanged', { userStatusChanged: user });
}
async publishNotification(notification: Notification): Promise<void> {
await this.publish(`notification:${notification.userId}`, {
notificationReceived: notification,
});
}
}
// Real-time Chat System
@Resolver()
export class ChatSubscriptionResolver {
constructor(private pubSubService: PubSubService) {}
@Subscription(() => ChatMessage, {
name: 'messageAdded',
filter: (payload, variables, context) => {
const { roomId } = variables;
const message = payload.messageAdded;
// Check if user is member of the room
return this.isUserInRoom(context.user.id, roomId) &&
message.roomId === roomId;
},
})
@UseGuards(GraphQLAuthGuard)
messageAdded(@Args('roomId') roomId: string) {
return this.pubSubService.asyncIterator(`chat:${roomId}`);
}
@Subscription(() => TypingIndicator, {
name: 'userTyping',
filter: (payload, variables, context) => {
const { roomId } = variables;
const typing = payload.userTyping;
// Don't send typing indicator to the user who is typing
return typing.roomId === roomId &&
typing.userId !== context.user.id;
},
})
@UseGuards(GraphQLAuthGuard)
userTyping(@Args('roomId') roomId: string) {
return this.pubSubService.asyncIterator(`typing:${roomId}`);
}
@Subscription(() => UserPresence, {
name: 'userPresenceChanged',
filter: (payload, variables, context) => {
const { roomId } = variables;
return payload.userPresenceChanged.roomId === roomId;
},
})
@UseGuards(GraphQLAuthGuard)
userPresenceChanged(@Args('roomId') roomId: string) {
return this.pubSubService.asyncIterator(`presence:${roomId}`);
}
@Mutation(() => ChatMessage)
@UseGuards(GraphQLAuthGuard)
async sendMessage(
@Args('input') input: SendMessageInput,
@Context() context: any,
): Promise<ChatMessage> {
const message = await this.chatService.sendMessage({
...input,
senderId: context.user.id,
});
// Publish to room subscribers
await this.pubSubService.publish(`chat:${input.roomId}`, {
messageAdded: message,
});
return message;
}
@Mutation(() => Boolean)
@UseGuards(GraphQLAuthGuard)
async startTyping(
@Args('roomId') roomId: string,
@Context() context: any,
): Promise<boolean> {
await this.pubSubService.publish(`typing:${roomId}`, {
userTyping: {
userId: context.user.id,
roomId,
isTyping: true,
timestamp: new Date(),
},
});
return true;
}
@Mutation(() => Boolean)
@UseGuards(GraphQLAuthGuard)
async stopTyping(
@Args('roomId') roomId: string,
@Context() context: any,
): Promise<boolean> {
await this.pubSubService.publish(`typing:${roomId}`, {
userTyping: {
userId: context.user.id,
roomId,
isTyping: false,
timestamp: new Date(),
},
});
return true;
}
private async isUserInRoom(userId: string, roomId: string): Promise<boolean> {
// Implementation to check room membership
return true; // Placeholder
}
}
// Live Data Subscription
@Resolver()
export class LiveDataResolver {
constructor(private pubSubService: PubSubService) {}
@Subscription(() => SystemMetrics, {
name: 'systemMetricsUpdated',
filter: (payload, variables, context) => {
// Only admins can subscribe to system metrics
return context.user?.role === 'admin';
},
})
@UseGuards(GraphQLAuthGuard, AdminGuard)
systemMetricsUpdated() {
return this.pubSubService.asyncIterator('systemMetrics');
}
@Subscription(() => OrderStatus, {
name: 'orderStatusChanged',
filter: (payload, variables, context) => {
const order = payload.orderStatusChanged;
// Users can only subscribe to their own orders
return order.userId === context.user.id;
},
})
@UseGuards(GraphQLAuthGuard)
orderStatusChanged() {
return this.pubSubService.asyncIterator('orderStatus');
}
// Progress tracking subscription
@Subscription(() => ProgressUpdate, {
name: 'progressUpdated',
filter: (payload, variables, context) => {
const progress = payload.progressUpdated;
return progress.userId === context.user.id &&
progress.taskId === variables.taskId;
},
})
@UseGuards(GraphQLAuthGuard)
progressUpdated(@Args('taskId') taskId: string) {
return this.pubSubService.asyncIterator(`progress:${taskId}`);
}
}
// Subscription Types
@ObjectType()
export class ChatMessage {
@Field(() => ID)
id: string;
@Field()
content: string;
@Field(() => User)
sender: User;
@Field()
roomId: string;
@Field(() => Date)
createdAt: Date;
@Field(() => MessageType)
type: MessageType;
}
@ObjectType()
export class TypingIndicator {
@Field()
userId: string;
@Field()
roomId: string;
@Field()
isTyping: boolean;
@Field(() => Date)
timestamp: Date;
}
@ObjectType()
export class UserPresence {
@Field()
userId: string;
@Field()
roomId: string;
@Field(() => PresenceStatus)
status: PresenceStatus;
@Field(() => Date)
lastSeen: Date;
}
@ObjectType()
export class SystemMetrics {
@Field(() => Float)
cpuUsage: number;
@Field(() => Float)
memoryUsage: number;
@Field(() => Int)
activeConnections: number;
@Field(() => Date)
timestamp: Date;
}
@ObjectType()
export class ProgressUpdate {
@Field()
taskId: string;
@Field()
userId: string;
@Field(() => Float)
percentage: number;
@Field()
status: string;
@Field({ nullable: true })
message?: string;
@Field(() => Date)
timestamp: Date;
}
enum MessageType {
TEXT = 'text',
IMAGE = 'image',
FILE = 'file',
SYSTEM = 'system',
}
enum PresenceStatus {
ONLINE = 'online',
OFFLINE = 'offline',
AWAY = 'away',
BUSY = 'busy',
}
registerEnumType(MessageType, { name: 'MessageType' });
registerEnumType(PresenceStatus, { name: 'PresenceStatus' });GraphQL Authentication erfordert besondere Aufmerksamkeit, da sowohl HTTP- als auch WebSocket-Verbindungen unterstützt werden müssen.
// GraphQL Auth Guard
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GraphQLAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
// For subscriptions, the context might be different
if (!request) {
const connection = ctx.getContext().connection;
return connection?.context || {};
}
return request;
}
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
if (err || !user) {
throw new UnauthorizedException('Authentication failed');
}
// Add user to GraphQL context
const gqlContext = GqlExecutionContext.create(context).getContext();
gqlContext.user = user;
return user;
}
}
// Role-based Guards
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) {
return true;
}
const ctx = GqlExecutionContext.create(context);
const user = ctx.getContext().user;
if (!user) {
return false;
}
return roles.some(role => user.roles?.includes(role));
}
}
// Custom Decorators
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().user;
},
);
// Resource-based Authorization Guard
@Injectable()
export class ResourceAuthGuard implements CanActivate {
constructor(
private userService: UserService,
private postService: PostService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const args = ctx.getArgs();
const user = ctx.getContext().user;
if (!user) {
return false;
}
// For update/delete operations, check ownership
if (args.id || args.input?.id) {
const resourceId = args.id || args.input.id;
const handlerName = context.getHandler().name;
if (handlerName.includes('Post')) {
return await this.canAccessPost(user, resourceId);
}
if (handlerName.includes('User')) {
return await this.canAccessUser(user, resourceId);
}
}
return true;
}
private async canAccessPost(user: any, postId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
const post = await this.postService.findById(postId);
return post?.authorId === user.id;
}
private async canAccessUser(user: any, userId: string): Promise<boolean> {
if (user.role === 'admin') {
return true;
}
return user.id === userId;
}
}
// Authentication Resolver
@Resolver()
export class AuthResolver {
constructor(
private authService: AuthService,
private userService: UserService,
) {}
@Mutation(() => AuthPayload)
async login(
@Args('input') input: LoginInput,
@Context() context: any,
): Promise<AuthPayload> {
const { user, accessToken, refreshToken } = await this.authService.login(
input.email,
input.password,
);
// Set HTTP-only cookie for refresh token
context.res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return {
user,
accessToken,
expiresIn: 900, // 15 minutes
};
}
@Mutation(() => AuthPayload)
async refreshToken(@Context() context: any): Promise<AuthPayload> {
const refreshToken = context.req.cookies?.refreshToken;
if (!refreshToken) {
throw new UnauthorizedException('Refresh token not found');
}
const { user, accessToken, refreshToken: newRefreshToken } =
await this.authService.refreshToken(refreshToken);
// Update refresh token cookie
context.res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return {
user,
accessToken,
expiresIn: 900,
};
}
@Mutation(() => Boolean)
@UseGuards(GraphQLAuthGuard)
async logout(@Context() context: any): Promise<boolean> {
// Clear refresh token cookie
context.res.clearCookie('refreshToken');
// Optionally blacklist the access token
await this.authService.blacklistToken(context.user.token);
return true;
}
@Query(() => User)
@UseGuards(GraphQLAuthGuard)
async me(@CurrentUser() user: any): Promise<User> {
return await this.userService.findById(user.id);
}
@Mutation(() => User)
async register(
@Args('input') input: RegisterInput,
): Promise<User> {
return await this.authService.register(input);
}
@Mutation(() => Boolean)
async forgotPassword(
@Args('email') email: string,
): Promise<boolean> {
await this.authService.sendPasswordResetEmail(email);
return true;
}
@Mutation(() => Boolean)
async resetPassword(
@Args('input') input: ResetPasswordInput,
): Promise<boolean> {
await this.authService.resetPassword(input.token, input.newPassword);
return true;
}
}
// Protected Resolvers with Authorization
@Resolver(() => Post)
export class PostResolver {
constructor(
private postService: PostService,
private userService: UserService,
) {}
@Query(() => [Post])
async posts(
@Args('filter', { nullable: true }) filter?: PostFilterInput,
): Promise<Post[]> {
// Public endpoint - no authentication required
return await this.postService.findPublished(filter);
}
@Query(() => [Post])
@UseGuards(GraphQLAuthGuard)
async myPosts(
@CurrentUser() user: any,
@Args('filter', { nullable: true }) filter?: PostFilterInput,
): Promise<Post[]> {
return await this.postService.findByAuthor(user.id, filter);
}
@Mutation(() => Post)
@UseGuards(GraphQLAuthGuard)
@Roles('author', 'admin')
async createPost(
@Args('input') input: CreatePostInput,
@CurrentUser() user: any,
): Promise<Post> {
return await this.postService.create({
...input,
authorId: user.id,
});
}
@Mutation(() => Post)
@UseGuards(GraphQLAuthGuard, ResourceAuthGuard)
async updatePost(
@Args('input') input: UpdatePostInput,
@CurrentUser() user: any,
): Promise<Post> {
// ResourceAuthGuard ensures user can modify this post
return await this.postService.update(input.id, input);
}
@Mutation(() => Boolean)
@UseGuards(GraphQLAuthGuard, ResourceAuthGuard)
async deletePost(
@Args('id') id: string,
@CurrentUser() user: any,
): Promise<boolean> {
return await this.postService.delete(id);
}
@Mutation(() => Post)
@UseGuards(GraphQLAuthGuard)
@Roles('admin', 'moderator')
async moderatePost(
@Args('input') input: ModeratePostInput,
@CurrentUser() user: any,
): Promise<Post> {
return await this.postService.moderate(input.postId, input.action, user.id);
}
}
// Authentication Types
@ObjectType()
export class AuthPayload {
@Field(() => User)
user: User;
@Field()
accessToken: string;
@Field(() => Int)
expiresIn: number;
}
@InputType()
export class LoginInput {
@Field()
@IsEmail()
email: string;
@Field()
@IsString()
password: string;
@Field({ defaultValue: false })
@IsOptional()
@IsBoolean()
rememberMe?: boolean;
}
@InputType()
export class RegisterInput {
@Field()
@IsEmail()
email: string;
@Field()
@IsString()
@MinLength(2)
@MaxLength(50)
name: string;
@Field()
@IsString()
@MinLength(8)
password: string;
@Field()
@IsString()
@MinLength(8)
confirmPassword: string;
}
@InputType()
export class ResetPasswordInput {
@Field()
@IsString()
token: string;
@Field()
@IsString()
@MinLength(8)
newPassword: string;
}
// Field-level Authorization
@Resolver(() => User)
export class UserResolver {
@ResolveField(() => String, { nullable: true })
async email(
@Parent() user: User,
@CurrentUser() currentUser?: any,
): Promise<string | null> {
// Only return email for the user themselves or admins
if (currentUser?.id === user.id || currentUser?.role === 'admin') {
return user.email;
}
return null;
}
@ResolveField(() => [Order])
@UseGuards(GraphQLAuthGuard)
async orders(
@Parent() user: User,
@CurrentUser() currentUser: any,
): Promise<Order[]> {
// Users can only see their own orders
if (currentUser.id !== user.id && currentUser.role !== 'admin') {
throw new ForbiddenException('Access denied');
}
return await this.orderService.findByUserId(user.id);
}
}GraphQL Integration in NestJS bietet eine mächtige und flexible Alternative zu REST APIs. Die typisierte Natur von GraphQL kombiniert mit NestJS’s TypeScript-Support ermöglicht es, sichere, performante und wartbare APIs zu entwickeln. Durch die Verwendung von DataLoader, Subscriptions und robusten Authentication-Mechanismen können auch komplexe Real-time-Anwendungen effizient umgesetzt werden.