GraphQL:快速入门

环境

Schema

在 GraphQL 中,Schema 描述数据类型和操作。Schema 由自定义类型(User Type)、查询(Query)、变更(Mutation)和订阅(Subscription)组成。

下面是一个示例 Schema:

 1type Query {
 2  posts: [Post]
 3  post(id: ID!): Post
 4}
 5
 6type Mutation {
 7  createPost(title: String!, content: String!): Post!
 8  updatePost(id: ID!, title: String!, content: String!): Post!
 9  deletePost(id: ID!): Boolean
10}
11
12type Subscription {
13  postAdded: Post
14}
15
16type Post {
17  id: ID!
18  title: String!
19  content: String!
20  author: User
21}
22
23type User {
24  id: ID!
25  name: String!
26  email: String!
27}

这里,Post 和 User 是自定义类型,Query、Mutation 和 Subscription 是操作。

1type Query {
2  posts: [Post]
3  post(id: ID!): Post
4}

表示 Query 操作有两个字段,分别是 posts 和 post。posts 返回一个 Post 类型的数组,post(id: ID!): Post 这是一个参数为 id 的函数,返回一个 Post 类型的对象。! 表示这个字段是必须的。

一个真实的 Schema 例子

  1type Auth {
  2  # JWT access token
  3  accessToken: JWT!
  4
  5  # JWT refresh token
  6  refreshToken: JWT!
  7  user: User!
  8}
  9
 10input ChangePasswordInput {
 11  newPassword: String!
 12  oldPassword: String!
 13}
 14
 15input CreatePostInput {
 16  content: String!
 17  title: String!
 18}
 19
 20# A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
 21scalar DateTime
 22
 23# A field whose value is a JSON Web Token (JWT): https://jwt.io/introduction.
 24scalar JWT
 25
 26input LoginInput {
 27  email: String!
 28  password: String!
 29}
 30
 31type Mutation {
 32  changePassword(data: ChangePasswordInput!): User!
 33  createPost(data: CreatePostInput!): Post!
 34  login(data: LoginInput!): Auth!
 35  refreshToken(token: JWT!): Token!
 36  signup(data: SignupInput!): Auth!
 37  updateUser(data: UpdateUserInput!): User!
 38}
 39
 40# Possible directions in which to order a list of items when provided an `orderBy` argument.
 41enum OrderDirection {
 42  asc
 43  desc
 44}
 45
 46type PageInfo {
 47  endCursor: String
 48  hasNextPage: Boolean!
 49  hasPreviousPage: Boolean!
 50  startCursor: String
 51}
 52
 53type Post {
 54  author: User
 55  content: String
 56
 57  # Identifies the date and time when the object was created.
 58  createdAt: DateTime!
 59  id: ID!
 60  published: Boolean!
 61  title: String!
 62
 63  # Identifies the date and time when the object was last updated.
 64  updatedAt: DateTime!
 65}
 66
 67type PostConnection {
 68  edges: [PostEdge!]
 69  pageInfo: PageInfo!
 70  totalCount: Int!
 71}
 72
 73type PostEdge {
 74  cursor: String!
 75  node: Post!
 76}
 77
 78input PostOrder {
 79  direction: OrderDirection!
 80  field: PostOrderField!
 81}
 82
 83# Properties by which post connections can be ordered.
 84enum PostOrderField {
 85  content
 86  createdAt
 87  id
 88  published
 89  title
 90  updatedAt
 91}
 92
 93type Query {
 94  hello(name: String!): String!
 95  helloWorld: String!
 96  me: User!
 97  post(postId: String!): Post!
 98  publishedPosts(
 99    after: String
100    before: String
101    first: Int
102    last: Int
103    orderBy: PostOrder
104    query: String
105    skip: Int
106  ): PostConnection!
107  userPosts(userId: String!): [Post!]!
108}
109
110# User role
111enum Role {
112  ADMIN
113  USER
114}
115
116input SignupInput {
117  email: String!
118  firstname: String
119  lastname: String
120  password: String!
121}
122
123type Subscription {
124  postCreated: Post!
125}
126
127type Token {
128  # JWT access token
129  accessToken: JWT!
130
131  # JWT refresh token
132  refreshToken: JWT!
133}
134
135input UpdateUserInput {
136  firstname: String
137  lastname: String
138}
139
140type User {
141  # Identifies the date and time when the object was created.
142  createdAt: DateTime!
143  email: String!
144  firstname: String
145  id: ID!
146  lastname: String
147  posts: [Post!]
148  role: Role!
149
150  # Identifies the date and time when the object was last updated.
151  updatedAt: DateTime!
152}

上面的 Schema 描述了一个博客系统的数据类型和操作。大多数内容都很好理解。除了下面这个可能需要解释一下:

 1type PostConnection {
 2  edges: [PostEdge!]
 3  pageInfo: PageInfo!
 4  totalCount: Int!
 5}
 6
 7type PostEdge {
 8  cursor: String!
 9  node: Post!
10}

在 GraphQL 中,连接(Connection)和边缘(Edge)是分页数据的常用表示形式。

  • 连接:表示分页数据的列表。

  • 边缘:则表示连接中的单个元素,包括游标和节点。

Connection 和 Edge 这两个术语最初是由 Facebook 在 GraphQL 规范中引入的,用于描述分页数据的传输和表示形式。对于信息流的内容,一个连续的信息流就是一个连接,而每条信息就是一个边缘。这样,还可以通过边缘的游标来定位信息流中的某一条信息。

在这个示例 Schema 中,PostConnection 表示帖子列表的连接。它包括一个 edges 数组,每个元素都是一个 PostEdge 对象,表示一个帖子的边缘。

PostEdge 包括两个字段:cursor 和 node。cursor 是一个字符串类型的字段,它表示帖子边缘在连接中的位置。node 是一个 Post 类型的字段,它表示帖子节点本身。当你查询 PostConnection 时,你将获得一个包含多个 PostEdge 对象的 edges 数组。

读者可能疑问为什么使用 cursor 而非 id。这是因为 id 是唯一的,而 cursor 不必要唯一。游标通常是由服务器随机生成的,不透明的字符串。这意味着,它的值只能由服务器生成和解析。客户端不需要了解游标的具体含义,只需要在后续查询中将游标值作为参数传递给服务器即可。

作为客户端使用 GraphQL

我们以一个完整的博客使用流程来说明如何使用 GraphQL。经历如下步骤:

  1. 注册用户

  2. 登录

  3. 创建帖子

  4. 查询帖子列表

  5. 查询帖子详情

  6. 更新帖子

  7. 删除帖子

  8. 注销

1. 注册用户

们使用 signup 变更来创建新用户。signup 变更需要一个包含 emailpasswordfirstnamelastnameSignupInput 输入对象,并返回一个包含访问令牌、刷新令牌和用户信息的 Auth 对象。

可以使用以下查询:

 1mutation {
 2  signup(data: {
 3    email: "test@example.com",
 4    password: "password",
 5    firstname: "John",
 6    lastname: "Doe"
 7  }) {
 8    accessToken
 9    refreshToken
10    user {
11      id
12      email
13      firstname
14      lastname
15      role
16    }
17  }
18}

执行上述查询后,返回:

 1{
 2  "data": {
 3    "signup": {
 4      "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbGc3YXBheTMwMDAwa2NvMnQ5NnUzaXVvIiwiaWF0IjoxNjgwOTE3MjAzLCJleHAiOjE2ODA5MTczMjN9.5B5E5bm5a-8o505HMzi82NTtc06v7gWy9-1c1L7p354",
 5      "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbGc3YXBheTMwMDAwa2NvMnQ5NnUzaXVvIiwiaWF0IjoxNjgwOTE3MjAzLCJleHAiOjE2ODE1MjIwMDN9.8UaJU_YJW7rUq9-eRZjef9EqVumhQBMXr_1Ib9yb1Iw",
 6      "user": {
 7        "id": "clg7apay30000kco2t96u3iuo",
 8        "email": "test@example.com",
 9        "firstname": "John",
10        "lastname": "Doe",
11        "role": "USER"
12      }
13    }
14  }
15}

现在,我们已经注册了一个新用户,并收到了访问令牌和刷新令牌。

访问令牌用于访问需要身份验证的资源,刷新令牌用于获取新的访问令牌。

2. 登录

使用 login 变更来进行登录。login 变更需要一个包含 emailpasswordLoginInput 输入对象,并返回一个包含访问令牌、刷新令牌和用户信息的 Auth 对象。

使用以下查询:

 1mutation {
 2  login(data: {
 3    email: "test@example.com",
 4    password: "password"
 5  }) {
 6    accessToken
 7    refreshToken
 8    user {
 9      id
10      email
11      firstname
12      lastname
13      role
14    }
15  }
16}

执行上述查询后,返回:

 1{
 2  "data": {
 3    "login": {
 4      "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbGc3YXBheTMwMDAwa2NvMnQ5NnUzaXVvIiwiaWF0IjoxNjgwOTIxNzcyLCJleHAiOjE2ODA5MjE4OTJ9.1e7Dk5wibQ4UCCcvzs_nyBOYkiB7x2hXRzLPMG9aznI",
 5      "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbGc3YXBheTMwMDAwa2NvMnQ5NnUzaXVvIiwiaWF0IjoxNjgwOTIxNzcyLCJleHAiOjE2ODE1MjY1NzJ9.N50PYxSSD2IuAU7aW4AtHvV_sS0g0S-RtjJ6dI0H7mI",
 6      "user": {
 7        "id": "clg7apay30000kco2t96u3iuo",
 8        "email": "test@example.com",
 9        "firstname": "John",
10        "lastname": "Doe",
11        "role": "USER"
12      }
13    }
14  }
15}

3. 创建帖子

现在可以使用访问令牌来创建帖子。在 Playground 的 HTTP Headers 中添加 Authorization 头,值为 Bearer <accessToken>。例如:

1{
2  "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbGc3YXBheTMwMDAwa2NvMnQ5NnUzaXVvIiwiaWF0IjoxNjgwOTIxNzcyLCJleHAiOjE2ODA5MjE4OTJ9.1e7Dk5wibQ4UCCcvzs_nyBOYkiB7x2hXRzLPMG9aznI"
3}

如果不设置访问令牌,会得到 Unauthorized 响应。

使用以下查询:

 1mutation {
 2  createPost(data: {
 3    title: "My First Blog Post",
 4    content: "This is my first blog post using GraphQL"
 5  }) {
 6    id
 7    title
 8    content
 9    published
10    author {
11      id
12      email
13      firstname
14      lastname
15    }
16  }
17}

执行上述查询后,返回:

 1{
 2  "data": {
 3    "createPost": {
 4      "id": "clg7dg0r20003kco2tkcx98d4",
 5      "title": "My First Blog Post",
 6      "content": "This is my first blog post using GraphQL",
 7      "published": true,
 8      "author": {
 9        "id": "clg7apay30000kco2t96u3iuo",
10        "email": "test@example.com",
11        "firstname": "John",
12        "lastname": "Doe"
13      }
14    }
15  }
16}

现在,我们已经创建了一个新帖子,并且可以在查询帖子列表中看到它。

4. 查询帖子列表

现在,我们想查询所有已发布的帖子的列表。在 GraphQL Schema 中,我们使用 publishedPosts 查询来获取已发布的帖子。publishedPosts 查询接受以下参数:

  • after:分页游标,用于获取后续页的数据。

  • before:分页游标,用于获取先前页的数据。

  • first:要返回的第一页中的帖子数。

  • last:要返回的最后一页中的帖子数。

  • orderBy:用于排序帖子的字段和方向。

  • query:用于搜索帖子的搜索字符串。

  • skip:要跳过的帖子数。

使用以下查询:

 1query {
 2  publishedPosts(first: 10) {
 3    edges {
 4      node {
 5        id
 6        title
 7        content
 8        author {
 9          id
10          email
11          firstname
12          lastname
13        }
14      }
15    }
16  }
17}

执行上述查询后,返回:

 1{
 2  "data": {
 3    "publishedPosts": {
 4      "edges": [
 5        {
 6          "node": {
 7            "id": "clg78awi60001kc2b327lbr8x",
 8            "title": "Join us for Prisma Day 2019 in Berlin",
 9            "content": "https://www.prisma.io/day/",
10            "author": {
11              "id": "clg78awi60000kc2bu023og4d",
12              "email": "lisa@simpson.com",
13              "firstname": "Lisa",
14              "lastname": "Simpson"
15            }
16          }
17        },
18        {
19          "node": {
20            "id": "clg78awif0007kc2b1k85yo4r",
21            "title": "Subscribe to GraphQL Weekly for community news",
22            "content": "https://graphqlweekly.com/",
23            "author": {
24              "id": "clg78awif0006kc2b3wdoe1zu",
25              "email": "bart@simpson.com",
26              "firstname": "Bart",
27              "lastname": "Simpson"
28            }
29          }
30        },
31        {
32          "node": {
33            "id": "clg7dg0r20003kco2tkcx98d4",
34            "title": "My First Blog Post",
35            "content": "This is my first blog post using GraphQL",
36            "author": {
37              "id": "clg7apay30000kco2t96u3iuo",
38              "email": "test@example.com",
39              "firstname": "John",
40              "lastname": "Doe"
41            }
42          }
43        }
44      ]
45    }
46  }
47}

现在,我们已经成功查询到了所有已发布的帖子,并且可以在结果中看到我们创建的帖子。

5. 查询帖子详情

我们想查询我们刚刚创建的帖子的详细信息。在 GraphQL Schema 中,我们使用 post 查询来获取单个帖子。post 查询接受一个名为 postId 的字符串参数,并返回一个包含帖子信息的 Post 对象。

使用以下查询:

 1query {
 2  post(postId: "clg7dg0r20003kco2tkcx98d4") {
 3    id
 4    title
 5    content
 6    published
 7    author {
 8      id
 9      email
10      firstname
11      lastname
12    }
13  }
14}

执行上述查询后,返回:

 1{
 2  "data": {
 3    "post": {
 4      "id": "clg7dg0r20003kco2tkcx98d4",
 5      "title": "My First Blog Post",
 6      "content": "This is my first blog post using GraphQL",
 7      "published": true,
 8      "author": {
 9        "id": "clg7apay30000kco2t96u3iuo",
10        "email": "test@example.com",
11        "firstname": "John",
12        "lastname": "Doe"
13      }
14    }
15  }
16}

现在,我们已经成功查询到了我们刚刚创建的帖子的详细信息。

6. 更新帖子

现在我们想更新我们刚刚创建的帖子。在 GraphQL Schema 中,我们使用 updatePost 变更来更新帖子。updatePost 变更需要一个包含 postIdtitlecontentUpdatePostInput 输入对象,并返回一个包含帖子信息的 Post 对象。

使用以下查询:

 1mutation {
 2  updatePost(data: {
 3    postId: "clg7dg0r20003kco2tkcx98d4",
 4    title: "My Updated Blog Post",
 5    content: "This is my updated blog post using GraphQL"
 6  }) {
 7    id
 8    title
 9    content
10    published
11    author {
12      id
13      email
14      firstname
15      lastname 
16    }
17  }
18}

执行上述查询后,返回:

 1{
 2  "error": {
 3    "errors": [
 4      {
 5        "message": "Cannot query field \"updatePost\" on type \"Mutation\". Did you mean \"createPost\" or \"updateUser\"?",
 6        "locations": [
 7          {
 8            "line": 2,
 9            "column": 3
10          }
11        ],
12        "extensions": {
13          "code": "GRAPHQL_VALIDATION_FAILED",
14          "stacktrace": [
15            "GraphQLError: Cannot query field \"updatePost\" on type \"Mutation\". Did you mean \"createPost\" or \"updateUser\"?",
16            "    at Object.Field (<SECRET>/node_modules/graphql/validation/rules/FieldsOnCorrectTypeRule.js:51:13)",
17            "    at Object.enter (<SECRET>/node_modules/graphql/language/visitor.js:301:32)",
18            "    at Object.enter (<SECRET>/node_modules/graphql/utilities/TypeInfo.js:391:27)",
19            "    at visit (<SECRET>/node_modules/graphql/language/visitor.js:197:21)",
20            "    at validate (<SECRET>/node_modules/graphql/validation/validate.js:91:24)",
21            "    at processGraphQLRequest (<SECRET>/node_modules/@apollo/server/src/requestPipeline.ts:245:38)",
22            "    at processTicksAndRejections (node:internal/process/task_queues:95:5)",
23            "    at internalExecuteOperation (<SECRET>/node_modules/@apollo/server/src/ApolloServer.ts:1290:12)",
24            "    at runHttpQuery (<SECRET>/node_modules/@apollo/server/src/runHttpQuery.ts:232:27)",
25            "    at runPotentiallyBatchedHttpQuery (<SECRET>/node_modules/@apollo/server/src/httpBatching.ts:85:12)"
26          ]
27        }
28      }
29    ]
30  }
31}

这是因为还没有实现 updatePost 变更。下面我们来实现这个变更。

在文件 src/posts/posts.resolver.ts 添加如下代码:

 1  @UseGuards(GqlAuthGuard)
 2  @Mutation(() => Post)
 3  async updatePost(
 4    @UserEntity() user: User,
 5    @Args('data') data: UpdatePostInput,
 6  ) {
 7    const post = await this.prisma.post.findUnique({
 8      where: { id: data.postId },
 9    });
10
11    if (!post || post.authorId !== user.id) {
12      throw new ForbiddenException('You can only update your own posts.');
13    }
14    return this.prisma.post.update({
15      where: { id: data.postId },
16      data: {
17        title: data.title,
18        content: data.content,
19      },
20      include: {
21        author: true,
22      },
23    });
24  }

创建如下 src/posts/dto/update-post.input.ts 文件:

 1import { InputType, Field } from '@nestjs/graphql';
 2import { IsNotEmpty } from 'class-validator';
 3
 4@InputType()
 5export class UpdatePostInput {
 6  @Field()
 7  @IsNotEmpty()
 8  postId: string;
 9
10  @Field()
11  @IsNotEmpty()
12  title: string;
13
14  @Field()
15  @IsNotEmpty()
16  content: string;
17}

再次尝试,返回:

 1{
 2  "data": {
 3    "updatePost": {
 4      "id": "clg7dg0r20003kco2tkcx98d4",
 5      "title": "My Updated Blog Post",
 6      "content": "This is my updated blog post using GraphQL",
 7      "published": true,
 8      "author": {
 9        "id": "clg7apay30000kco2t96u3iuo",
10        "email": "test@example.com",
11        "firstname": "John",
12        "lastname": "Doe"
13      }
14    }
15  }
16}

现在,我们已经成功更新了我们刚刚创建的帖子。

7. 删除帖子

现在我们想删除我们刚刚创建的帖子。在 GraphQL Schema 中,我们使用 deletePost 变更来删除帖子。deletePost 变更需要一个名为 postId 的字符串参数,并返回一个包含布尔值的 Boolean 对象,表示是否成功删除帖子。

我们首先实现它:

路径:src/posts/posts.resolver.ts

 1  @UseGuards(GqlAuthGuard)
 2  @Mutation(() => Boolean)
 3  async deletePost(
 4    @UserEntity() user: User,
 5    @Args('postId') postId: string,
 6  ) {
 7    const post = await this.prisma.post.findUnique({
 8      where: { id: postId },
 9    });
10
11    if (!post || post.authorId !== user.id) {
12      throw new ForbiddenException('You can only delete your own posts.');
13    }
14
15    await this.prisma.post.delete({
16      where: { id: postId },
17    });
18
19    return true;
20  }

使用以下查询:

1mutation {
2  deletePost(postId: "clg7dg0r20003kco2tkcx98d4")
3}

执行上述查询后,返回:

1{
2  "data": {
3    "deletePost": true
4  }
5}

现在,我们已经成功删除了我们刚刚创建的帖子。

8. 注销

哈哈,根本没有注销功能。我们只需要删除 JWT 令牌即可。


本文使用 ChatGPT Plus - 3.5 Default 辅助编写。