AgentSkillsCN

graphql-integration

根据项目约定,为Rails模型、服务、控制器以及后台任务生成完整且可运行的RSpec测试。当新代码尚未配套测试时、在重构现有代码时,或在明确被要求增加测试覆盖率时,可选用此技能。

SKILL.md
--- frontmatter
name: graphql-integration
description: GraphQL code generation and integration patterns across API, web, and native apps. Use this skill when setting up GraphQL operations, configuring code generation, or integrating GraphQL queries and mutations.
license: MIT

GraphQL Integration Skill

Overview

This skill covers GraphQL schema, code generation, and integration patterns used across all three applications in the Game Critique project.

GraphQL Architecture

Code-First vs Schema-First

  • API (NestJS): Code-first approach - schema auto-generated from TypeScript decorators
  • Web & Native: Schema-first - consume generated schema from API

Schema Location

yaml
# graphql.config.yml (root)
schema: "apps/api/src/schema.gql"

The schema is automatically generated by NestJS on startup and stored at apps/api/src/schema.gql.

API: Code-First Schema Generation

Creating GraphQL Types

typescript
// DTOs serve as GraphQL types
import { ObjectType, Field, Int, ArgsType, InputType } from '@nestjs/graphql';

@ObjectType()
export class GameDTO {
  @Field(() => Int)
  hltbId: number;

  @Field()
  title: string;

  @Field({ nullable: true })
  description?: string;

  @Field(() => [String])
  platforms: string[];

  @Field(() => Float, { nullable: true })
  rating?: number;
}

@ObjectType()
export class PaginatedGamesDTO {
  @Field(() => [GameDTO])
  games: GameDTO[];

  @Field(() => Int)
  total: number;

  @Field(() => Int)
  page: number;
}

Input Types and Arguments

typescript
@InputType()
export class CreateGameInput {
  @Field()
  title: string;

  @Field({ nullable: true })
  description?: string;

  @Field(() => [Int])
  platformIds: number[];
}

@ArgsType()
export class GetGamesArgs {
  @Field({ nullable: true })
  search?: string;

  @Field(() => Int, { defaultValue: 10 })
  take: number;

  @Field(() => Int, { defaultValue: 0 })
  skip: number;
}

Resolvers with Queries and Mutations

typescript
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';

@Resolver(() => GameDTO)
export class GamesResolver {
  constructor(private readonly gamesService: GamesService) {}

  @Query(() => GameDTO, { name: 'game', nullable: true })
  async getGame(@Args('id', { type: () => Int }) id: number) {
    return this.gamesService.findById(id);
  }

  @Query(() => PaginatedGamesDTO, { name: 'games' })
  async getGames(@Args() args: GetGamesArgs) {
    return this.gamesService.findAll(args);
  }

  @Mutation(() => GameDTO, { name: 'createGame' })
  async createGame(@Args('input') input: CreateGameInput) {
    return this.gamesService.create(input);
  }

  @Mutation(() => Boolean, { name: 'deleteGame' })
  async deleteGame(@Args('id', { type: () => Int }) id: number) {
    await this.gamesService.delete(id);
    return true;
  }
}

Custom Scalars

typescript
import { Scalar, CustomScalar } from '@nestjs/graphql';
import { Kind, ValueNode } from 'graphql';

@Scalar('DateTime')
export class DateTimeScalar implements CustomScalar<string, Date> {
  description = 'DateTime custom scalar type';

  parseValue(value: string): Date {
    return new Date(value);
  }

  serialize(value: Date): string {
    return value.toISOString();
  }

  parseLiteral(ast: ValueNode): Date {
    if (ast.kind === Kind.STRING) {
      return new Date(ast.value);
    }
    return null;
  }
}

Field Resolvers

typescript
@Resolver(() => UserDTO)
export class UserResolver {
  @ResolveField(() => [GameDTO])
  async games(@Parent() user: UserDTO) {
    return this.gamesService.findByUserId(user.id);
  }

  @ResolveField(() => Int)
  async gameCount(@Parent() user: UserDTO) {
    return this.gamesService.countByUserId(user.id);
  }
}

Native App: Apollo Client Integration

Code Generation Configuration

typescript
// apps/native/codegen.ts
import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: process.env.EXPO_PUBLIC_GRAPHQL_ENDPOINT || "http://localhost:3000/graphql",
  documents: ["modules/**/*.graphql"],
  generates: {
    "__generated__/types.ts": {
      plugins: ["typescript"]
    },
    "./": {
      preset: "near-operation-file",
      presetConfig: {
        extension: ".generated.ts",
        baseTypesPath: "__generated__/types.ts",
      },
      plugins: ["typescript-operations", "typescript-react-apollo"],
      config: {
        withHooks: true,
      },
    },
  },
};

export default config;

Writing GraphQL Operations

graphql
# modules/games_status/use_game_status/game_status.graphql
query GetGameStatus($gameId: Int!) {
  gameStatus(gameId: $gameId) {
    id
    status
    rating
    hoursPlayed
    completedAt
    game {
      hltbId
      title
      coverUrl
    }
  }
}

mutation UpsertGameStatus($input: UpsertGameStatusArgsDTO!) {
  upsertGameStatus(upsertGameStatusArgs: $input) {
    message
  }
}

Using Generated Apollo Hooks

typescript
import {
  useGetGameStatusQuery,
  useUpsertGameStatusMutation,
} from './game_status.generated';

export function GameStatusScreen({ gameId }) {
  // Query with loading and error states
  const { data, loading, error, refetch } = useGetGameStatusQuery({
    variables: { gameId },
    fetchPolicy: 'cache-and-network',
  });

  // Mutation with optimistic updates
  const [upsertStatus, { loading: saving }] = useUpsertGameStatusMutation({
    onCompleted: () => {
      toast.show('Status updated!');
      refetch();
    },
    onError: (error) => {
      toast.show(`Error: ${error.message}`);
    },
  });

  const handleSubmit = (values) => {
    upsertStatus({
      variables: {
        input: {
          gameId,
          status: values.status,
          rating: values.rating,
          hoursPlayed: values.hoursPlayed,
        },
      },
      optimisticResponse: {
        upsertGameStatus: {
          __typename: 'UpsertGameStatusResponse',
          message: 'Updating...',
        },
      },
    });
  };

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <GameStatusForm
      initialData={data?.gameStatus}
      onSubmit={handleSubmit}
      isSubmitting={saving}
    />
  );
}

Apollo Client Setup

typescript
// modules/graphql/apollo_provider.tsx
import { ApolloClient, InMemoryCache, createHttpLink, ApolloProvider } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import * as SecureStore from 'expo-secure-store';

const httpLink = createHttpLink({
  uri: process.env.EXPO_PUBLIC_GRAPHQL_ENDPOINT,
});

const authLink = setContext(async (_, { headers }) => {
  const token = await SecureStore.getItemAsync('authToken');
  
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          games: {
            keyArgs: ['search'],
            merge(existing, incoming, { args }) {
              if (args?.skip === 0) return incoming;
              return [...(existing || []), ...incoming];
            },
          },
        },
      },
    },
  }),
});

export function ApolloProvider({ children }) {
  return <ApolloProvider client={client}>{children}</ApolloProvider>;
}

Cache Management

typescript
// Manually update cache after mutation
const [deleteGame] = useDeleteGameMutation({
  update(cache, { data }) {
    cache.modify({
      fields: {
        games(existingGames, { readField }) {
          return existingGames.filter(
            (gameRef) => readField('id', gameRef) !== data.deleteGame.id
          );
        },
      },
    });
  },
});

// Refetch queries
const [createGame] = useCreateGameMutation({
  refetchQueries: ['GetGames', 'GetUserStats'],
  awaitRefetchQueries: true,
});

// Invalidate cache
const client = useApolloClient();
await client.resetStore(); // Clear entire cache
await client.refetchQueries({ include: ['GetGames'] }); // Refetch specific queries

Web App: React Query Integration

Code Generation Configuration

typescript
// apps/web/codegen-dev.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "http://localhost:3001/graphql",
  documents: "src/**/*.graphql",
  ignoreNoDocuments: true,
  generates: {
    "src/types.ts": {
      plugins: ["typescript"],
    },
    "src/": {
      preset: "near-operation-file",
      presetConfig: {
        extension: ".generated.ts",
        baseTypesPath: "types.ts",
      },
      plugins: ["typescript-operations", "typescript-react-query"],
      config: {
        withHooks: true,
        fetcher: "@/codegen/fetcher#fetchData",
        reactQueryVersion: 5,
      },
    },
  },
};

export default config;

Custom Fetcher

typescript
// src/codegen/fetcher.ts
export async function fetchData<TData, TVariables>(
  query: string,
  variables?: TVariables,
  options?: RequestInit['headers']
): Promise<TData> {
  const response = await fetch('http://localhost:3001/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...options,
    },
    body: JSON.stringify({
      query,
      variables,
    }),
  });

  const json = await response.json();

  if (json.errors) {
    const { message } = json.errors[0];
    throw new Error(message);
  }

  return json.data;
}

Fetcher with Auth

typescript
// Enhanced fetcher with Auth0 token
import { useAuth0 } from '@auth0/auth0-react';

export function useGraphQLFetcher() {
  const { getAccessTokenSilently } = useAuth0();

  return async <TData, TVariables>(
    query: string,
    variables?: TVariables
  ): Promise<TData> => {
    const token = await getAccessTokenSilently();
    
    return fetchData(query, variables, {
      authorization: `Bearer ${token}`,
    });
  };
}

Writing GraphQL Operations

graphql
# features/admin/users/use_users/users.graphql
query GetUsers($search: String, $take: Int!, $skip: Int!) {
  users(search: $search, take: $take, skip: $skip) {
    users {
      id
      oauthId
      profile {
        name
        email
        avatarUrl
      }
      role {
        role
      }
    }
    total
  }
}

mutation DeleteUser($userId: Int!) {
  deleteUser(userId: $userId)
}

Using Generated React Query Hooks

typescript
import { useGetUsersQuery, useDeleteUserMutation } from './users.generated';
import { useQueryClient } from '@tanstack/react-query';

export function UsersTable() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  // Query with pagination
  const { data, isLoading, error } = useGetUsersQuery({
    search,
    take: 10,
    skip: (page - 1) * 10,
  }, {
    staleTime: 5 * 60 * 1000, // 5 minutes
    keepPreviousData: true,
  });

  // Mutation with cache invalidation
  const queryClient = useQueryClient();
  const { mutate: deleteUser, isPending } = useDeleteUserMutation({
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['GetUsers'] });
      toast.success('User deleted');
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });

  if (isLoading) return <TableSkeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />
      
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>Role</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {data?.users.users.map((user) => (
            <tr key={user.id}>
              <td>{user.profile?.name}</td>
              <td>{user.profile?.email}</td>
              <td>{user.role?.role}</td>
              <td>
                <button
                  onClick={() => deleteUser({ userId: user.id })}
                  disabled={isPending}
                >
                  Delete
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      <Pagination
        currentPage={page}
        totalPages={Math.ceil(data.users.total / 10)}
        onPageChange={setPage}
      />
    </div>
  );
}

Optimistic Updates

typescript
const { mutate } = useUpdateUserRoleMutation({
  onMutate: async (variables) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['GetUsers'] });

    // Snapshot previous value
    const previousUsers = queryClient.getQueryData(['GetUsers']);

    // Optimistically update cache
    queryClient.setQueryData(['GetUsers'], (old) => {
      return {
        ...old,
        users: old.users.map((user) =>
          user.id === variables.userId
            ? { ...user, role: { role: variables.role } }
            : user
        ),
      };
    });

    return { previousUsers };
  },
  onError: (err, variables, context) => {
    // Rollback on error
    queryClient.setQueryData(['GetUsers'], context.previousUsers);
  },
  onSettled: () => {
    // Always refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['GetUsers'] });
  },
});

GraphQL Code Generation Workflow

Step-by-Step Process

  1. Define operation in API
typescript
// apps/api/src/modules/games/games.resolver.ts
@Query(() => GameDTO, { name: 'game' })
async getGame(@Args('id', { type: () => Int }) id: number) {
  return this.gamesService.findById(id);
}
  1. Start API to generate schema
bash
cd apps/api
yarn dev  # Schema auto-generated at src/schema.gql
  1. Create GraphQL operation file
graphql
# apps/native/modules/games/use_game/game.graphql
query GetGame($id: Int!) {
  game(id: $id) {
    hltbId
    title
    description
    platforms
    rating
  }
}
  1. Run code generation
bash
# Native app
cd apps/native
yarn generate-graph

# Web app
cd apps/web
yarn generate-codegen-dev
  1. Use generated hooks
typescript
import { useGetGameQuery } from './game.generated';

export function GameDetails({ id }) {
  const { data, loading } = useGetGameQuery({ variables: { id } });
  // ...
}

Best Practices

1. Co-locate Operations with Components

code
modules/games/
  game_details/
    game_details.tsx
    use_game/
      game.graphql
      game.generated.ts

2. Use Fragments for Reusability

graphql
fragment GameBasic on GameDTO {
  hltbId
  title
  coverUrl
}

query GetGame($id: Int!) {
  game(id: $id) {
    ...GameBasic
    description
    platforms
  }
}

query GetGames {
  games {
    ...GameBasic
  }
}

3. Proper Error Handling

typescript
const { data, error } = useGetGameQuery({ variables: { id } });

if (error) {
  if (error.message.includes('NOT_FOUND')) {
    return <NotFound />;
  }
  if (error.message.includes('UNAUTHORIZED')) {
    return <Unauthorized />;
  }
  return <ErrorMessage error={error} />;
}

4. Optimize with Field Selection

Only query what you need:

graphql
# Bad - fetching unnecessary data
query GetGames {
  games {
    hltbId
    title
    description
    platforms
    genres
    completionTime
    # ... everything
  }
}

# Good - only what's displayed
query GetGamesForList {
  games {
    hltbId
    title
    coverUrl
    rating
  }
}

5. Use Variables for Dynamic Queries

graphql
# Bad - hardcoded values
query GetGame {
  game(id: 123) {
    title
  }
}

# Good - use variables
query GetGame($id: Int!) {
  game(id: $id) {
    title
  }
}

6. Implement Proper Loading States

typescript
const { data, loading, error, refetch, networkStatus } = useGetGameQuery({
  variables: { id },
  notifyOnNetworkStatusChange: true,
});

const isRefetching = networkStatus === NetworkStatus.refetch;

if (loading && !isRefetching) return <Skeleton />;
if (error) return <Error />;
if (isRefetching) return <RefreshingIndicator />;

Common Patterns

Pagination

graphql
query GetGames($take: Int!, $skip: Int!) {
  games(take: $take, skip: $skip) {
    games {
      id
      title
    }
    total
  }
}

Infinite Scroll (Native)

typescript
const { data, fetchMore, loading } = useGetGamesQuery({
  variables: { take: 20, skip: 0 },
});

const loadMore = () => {
  fetchMore({
    variables: { skip: data.games.games.length },
    updateQuery: (prev, { fetchMoreResult }) => {
      return {
        games: {
          ...fetchMoreResult.games,
          games: [...prev.games.games, ...fetchMoreResult.games.games],
        },
      };
    },
  });
};

Search with Debouncing

typescript
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 500);

const { data } = useSearchGamesQuery(
  { search: debouncedSearch },
  { enabled: debouncedSearch.length >= 3 }
);

Debugging

Enable GraphQL DevTools

Native:

typescript
// Apollo DevTools plugin already configured
import { __DEV__ } from 'react-native';

const client = new ApolloClient({
  // ...
  connectToDevTools: __DEV__,
});

Web:

typescript
// React Query DevTools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  {children}
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

Logging Queries

typescript
// API - enable playground
GraphQLModule.forRoot({
  playground: true,
  debug: true,
});

// Native - log all operations
const link = ApolloLink.from([
  new ApolloLink((operation, forward) => {
    console.log('GraphQL Operation:', operation.operationName);
    return forward(operation);
  }),
  httpLink,
]);

Code Generation Commands

bash
# API - Schema auto-generates on start
cd apps/api
yarn dev

# Native - Generate from remote schema
cd apps/native
yarn generate-graph        # Local dev
yarn generate-graph-prod   # Production

# Web - Generate from remote schema
cd apps/web
yarn generate-codegen-dev

Common Issues & Solutions

Issue: Generated types not found

Solution: Ensure code generation completed successfully and restart TypeScript server

Issue: Schema changes not reflected

Solution: Restart API server to regenerate schema, then re-run codegen in clients

Issue: Authentication errors

Solution: Check token is properly passed in headers for both Apollo Client and fetcher

Issue: Cache not updating

Solution: Use refetchQueries, invalidateQueries, or manual cache updates