mcpField
G
graphql-schema-designer

graphql-schema-designer

github.com/patricio0312rev开发63217 次安装41 周前更新
代码仓库

关于 graphql-schema-designer

面向开发者的 GraphQL API 设计技能,帮助构建类型安全的 Schema 和 Resolver。内容涵盖 DataLoader 批处理、校验、订阅、错误处理与代码生成。

主要功能

  1. 01设计类型、查询和变更
  2. 02实现 Resolver
  3. 03用 DataLoader 批处理与缓存
  4. 04添加订阅和输入校验
  5. 05生成 TypeScript 类型
  6. 06编写 Schema 字段文档

适用场景

  1. 01规划新的 GraphQL API Schema
  2. 02减少 N+1 数据查询
  3. 03添加实时 API 更新
  4. 04生成类型化 Resolver 代码

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/patricio0312rev/skills --skill graphql-schema-designer

安装后在 Claude Code 中输入/graphql-schema-designer确认已加载。

详细介绍

GraphQL 架构设计器

通过合理的架构设计和解析器模式,构建高效、类型安全的 GraphQL API。

核心工作流

  1. 设计架构:定义类型、查询和变更
  2. 实现解析器:连接数据源
  3. 添加 DataLoader:批量处理并缓存查询
  4. 启用订阅:实时更新
  5. 添加验证:输入验证和错误处理
  6. 编写文档:架构描述

项目设置

npm install @apollo/server graphql graphql-tag dataloader
npm install -D @graphql-codegen/cli @graphql-codegen/typescript

架构设计

类型定义
# schema.graphql
scalar DateTime
scalar JSON

"""
A registered user in the system
"""
type User {
  id: ID!
  email: String!
  name: String!
  avatar: String
  role: UserRole!
  posts: [Post!]!
  comments: [Comment!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

enum UserRole {
  ADMIN
  USER
  GUEST
}

type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!
  comments: [Comment!]!
  tags: [Tag!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

type Tag {
  id: ID!
  name: String!
  posts: [Post!]!
}

"""
Pagination info for cursor-based pagination
"""
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type PostEdge {
  cursor: String!
  node: Post!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
查询
type Query {
  """
  Get current authenticated user
  """
  me: User

  """
  Get a user by ID
  """
  user(id: ID!): User

  """
  List all users with optional filtering
  """
  users(
    role: UserRole
    search: String
    limit: Int = 10
    offset: Int = 0
  ): [User!]!

  """
  Get a post by ID
  """
  post(id: ID!): Post

  """
  List posts with cursor pagination
  """
  posts(
    first: Int
    after: String
    last: Int
    before: String
    published: Boolean
    authorId: ID
  ): PostConnection!

  """
  Search posts by title or content
  """
  searchPosts(query: String!, limit: Int = 10): [Post!]!
}
变更
input CreateUserInput {
  email: String!
  name: String!
  password: String!
  role: UserRole = USER
}

input UpdateUserInput {
  name: String
  avatar: String
}

input CreatePostInput {
  title: String!
  content: String!
  published: Boolean = false
  tagIds: [ID!]
}

input UpdatePostInput {
  title: String
  content: String
  published: Boolean
  tagIds: [ID!]
}

type Mutation {
  # Auth
  signUp(input: CreateUserInput!): AuthPayload!
  signIn(email: String!, password: String!): AuthPayload!
  signOut: Boolean!

  # Users
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!

  # Posts
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  publishPost(id: ID!): Post!

  # Comments
  createComment(postId: ID!, content: String!): Comment!
  deleteComment(id: ID!): Boolean!
}

type AuthPayload {
  token: String!
  user: User!
}
订阅
type Subscription {
  """
  Subscribe to new posts
  """
  postCreated: Post!

  """
  Subscribe to comments on a specific post
  """
  commentAdded(postId: ID!): Comment!

  """
  Subscribe to post updates
  """
  postUpdated(id: ID!): Post!
}

解析器

基本解析器结构
// resolvers/index.ts
import { Resolvers } from '../generated/graphql';
import { userResolvers } from './user';
import { postResolvers } from './post';
import { commentResolvers } from './comment';
import { scalarResolvers } from './scalars';

export const resolvers: Resolvers = {
  ...scalarResolvers,
  Query: {
    ...userResolvers.Query,
    ...postResolvers.Query,
  },
  Mutation: {
    ...userResolvers.Mutation,
    ...postResolvers.Mutation,
    ...commentResolvers.Mutation,
  },
  Subscription: {
    ...postResolvers.Subscription,
    ...commentResolvers.Subscription,
  },
  User: userResolvers.User,
  Post: postResolvers.Post,
  Comment: commentResolvers.Comment,
};
用户解析器
// resolvers/user.ts
import { Resolvers } from '../generated/graphql';
import { Context } from '../context';

export const userResolvers: Resolvers<Context> = {
  Query: {
    me: async (_, __, { user }) => {
      if (!user) return null;
      return user;
    },

    user: async (_, { id }, { dataSources }) => {
      return dataSources.users.findById(id);
    },

    users: async (_, { role, search, limit, offset }, { dataSources }) => {
      return dataSources.users.findMany({ role, search, limit, offset });
    },
  },

  Mutation: {
    signUp: async (_, { input }, { dataSources }) => {
      const user = await dataSources.users.create(input);
      const token = generateToken(user);
      return { token, user };
    },

    updateUser: async (_, { id, input }, { dataSources, user }) => {
      // Authorization check
      if (user?.id !== id && user?.role !== 'ADMIN') {
        throw new ForbiddenError('Not authorized');
      }
      return dataSources.users.update(id, input);
    },
  },

  User: {
    posts: async (parent, _, { loaders }) => {
      return loaders.postsByAuthor.load(parent.id);
    },

    comments: async (parent, _, { loaders }) => {
      return loaders.commentsByAuthor.load(parent.id);
    },
  },
};
带分页的帖子解析器
// resolvers/post.ts
import { Resolvers } from '../generated/graphql';

export const postResolvers: Resolvers<Context> = {
  Query: {
    post: async (_, { id }, { dataSources }) => {
      return dataSources.posts.findById(id);
    },

    posts: async (_, { first, after, last, before, published, authorId }, { dataSources }) => {
      const { edges, pageInfo, totalCount } = await dataSources.posts.findMany({
        first,
        after,
        last,
        before,
        where: { published, authorId },
      });

      return { edges, pageInfo, totalCount };
    },

    searchPosts: async (_, { query, limit }, { dataSources }) => {
      return dataSources.posts.search(query, limit);
    },
  },

  Mutation: {
    createPost: async (_, { input }, { dataSources, user, pubsub }) => {
      if (!user) throw new AuthenticationError('Must be logged in');

      const post = await dataSources.posts.create({
        ...input,
        authorId: user.id,
      });

      // Publish to subscribers
      pubsub.publish('POST_CREATED', { postCreated: post });

      return post;
    },

    publishPost: async (_, { id }, { dataSources, user }) => {
      const post = await dataSources.posts.findById(id);

      if (post.authorId !== user?.id) {
        throw new ForbiddenError('Not your post');
      }

      return dataSources.posts.update(id, { published: true });
    },
  },

  Subscription: {
    postCreated: {
      subscribe: (_, __, { pubsub }) => pubsub.asyncIterator(['POST_CREATED']),
    },

    postUpdated: {
      subscribe: (_, { id }, { pubsub }) => {
        return pubsub.asyncIterator([`POST_UPDATED_${id}`]);
      },
    },
  },

  Post: {
    author: async (parent, _, { loaders }) => {
      return loaders.users.load(parent.authorId);
    },

    comments: async (parent, _, { loaders }) => {
      return loaders.commentsByPost.load(parent.id);
    },

    tags: async (parent, _, { loaders }) => {
      return loaders.tagsByPost.load(parent.id);
    },
  },
};

DataLoader 模式

创建加载器
// loaders/index.ts
import DataLoader from 'dataloader';
import { db } from '../db';

export function createLoaders() {
  return {
    users: new DataLoader<string, User>(async (ids) => {
      const users = await db.user.findMany({
        where: { id: { in: [...ids] } },
      });
      // Return in same order as requested
      return ids.map((id) => users.find((u) => u.id === id)!);
    }),

    postsByAuthor: new DataLoader<string, Post[]>(async (authorIds) => {
      const posts = await db.post.findMany({
        where: { authorId: { in: [...authorIds] } },
      });
      // Group by authorId
      return authorIds.map((authorId) =>
        posts.filter((p) => p.authorId === authorId)
      );
    }),

    commentsByPost: new DataLoader<string, Comment[]>(async (postIds) => {
      const comments = await db.comment.findMany({
        where: { postId: { in: [...postIds] } },
        orderBy: { createdAt: 'desc' },
      });
      return postIds.map((postId) =>
        comments.filter((c) => c.postId === postId)
      );
    }),

    tagsByPost: new DataLoader<string, Tag[]>(async (postIds) => {
      const postTags = await db.postTag.findMany({
        where: { postId: { in: [...postIds] } },
        include: { tag: true },
      });
      return postIds.map((postId) =>
        postTags.filter((pt) => pt.postId === postId).map((pt) => pt.tag)
      );
    }),
  };
}

export type Loaders = ReturnType<typeof createLoaders>;
上下文设置
// context.ts
import { createLoaders, Loaders } from './loaders';
import { DataSources } from './dataSources';
import { PubSub } from 'graphql-subscriptions';

export interface Context {
  user: User | null;
  dataSources: DataSources;
  loaders: Loaders;
  pubsub: PubSub;
}

const pubsub = new PubSub();

export async function createContext({ req }): Promise<Context> {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;

  return {
    user,
    dataSources: new DataSources(),
    loaders: createLoaders(), // New loaders per request
    pubsub,
  };
}

Apollo Server 设置

// server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';

async function startServer() {
  const app = express();
  const httpServer = http.createServer(app);

  // WebSocket server for subscriptions
  const wsServer = new WebSocketServer({
    server: httpServer,
    path: '/graphql',
  });

  const serverCleanup = useServer(
    {
      schema,
      context: async (ctx) => createContext(ctx),
    },
    wsServer
  );

  const server = new ApolloServer({
    typeDefs,
    resolvers,
    plugins: [
      ApolloServerPluginDrainHttpServer({ httpServer }),
      {
        async serverWillStart() {
          return {
            async drainServer() {
              await serverCleanup.dispose();
            },
          };
        },
      },
    ],
  });

  await server.start();

  app.use(
    '/graphql',
    cors(),
    express.json(),
    expressMiddleware(server, {
      context: createContext,
    })
  );

  httpServer.listen(4000, () => {
    console.log('Server ready at http://localhost:4000/graphql');
  });
}

startServer();

错误处理

// errors.ts
import { GraphQLError } from 'graphql';

export class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
}

export class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'FORBIDDEN' },
    });
  }
}

export class NotFoundError extends GraphQLError {
  constructor(resource: string) {
    super(`${resource} not found`, {
      extensions: { code: 'NOT_FOUND' },
    });
  }
}

export class ValidationError extends GraphQLError {
  constructor(message: string, field?: string) {
    super(message, {
      extensions: {
        code: 'BAD_USER_INPUT',
        field,
      },
    });
  }
}

代码生成

# codegen.yml
schema: "./schema.graphql"
generates:
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-resolvers
    config:
      contextType: ../context#Context
      mappers:
        User: ../models#UserModel
        Post: ../models#PostModel
      useIndexSignature: true
npx graphql-codegen

最佳实践

  1. 使用 DataLoader:避免 N+1 查询
  2. 先设计架构:采用 API 优先的方法
  3. 使用游标分页:适用于大型数据集
  4. 添加描述:为每种类型和字段编写文档
  5. 妥善处理错误:使用自定义错误类型
  6. 生成类型:使用代码生成工具确保类型安全
  7. 验证输入:处理前先进行清理
  8. 谨慎使用订阅:仅用于实时需求

输出检查清单

每个 GraphQL API 都应包含:

  • 设计良好的类型定义
  • 支持适当筛选和分页的查询
  • 带输入验证的变更
  • 用于批量处理的 DataLoader
  • 自定义错误类型
  • 身份验证/授权
  • 代码生成设置
  • 架构文档
  • 订阅支持(如有需要)
  • 速率限制

评论

支持 Markdown