五、使用真实项目理解 GraphQL
GraphQL是一种 API 查询语言,可帮助 API 处理现有数据。它提供了 API 中数据的完整描述,您只能请求所需的确切数据,仅此而已。如果他们需要的话,它还可以使 API 的改进变得更容易,并且有非常强大的开发工具。
在本章中,我们将通过创建一个基本的登录和用户注册系统来学习如何在实际项目中使用 GraphQL。
本章将介绍以下主题:
- 安装 PostgreSQL
- 使用
.env
文件创建环境变量 - 配置 Apollo 服务器
- 定义 GraphQL 查询和突变
- 使用解析器
- 创建续集模型
- 实现 JWTs
- 使用 GraphQL
- 执行身份验证
技术要求
要完成本章,您需要以下内容:
- Node.js 12+
- Visual Studio 代码
- PostgreSQL
- 自制(https://brew.sh )
- pgAdmin 4(https://www.pgadmin.org/download/ )
- OmniDB(https://omnidb.org )
您可以在本书的 GitHub 存储库中找到本章的代码:https://github.com/PacktPublishing/React-17-Design-Patterns-and-Best-Practices-Third-Edition/tree/main/Chapter05 。
安装 PostgreSQL
对于本例,我们将使用 PostgreSQL 数据库,因此您需要安装 PostgreSQL 才能在您的计算机上运行此项目。
如果您有 macOS 机器,安装 PostgreSQL 的最简单方法是使用自制软件。您只需运行以下命令:
brew install postgres
安装后,需要运行以下命令:
ln -sfv /usr/local/opt/postgresql/*.plist ~/Library/LaunchAgents
然后,您可以创建两个新别名来启动和停止 PostgreSQL server:
alias pg_start="launchctl load ~/Library/LaunchAgents"
alias pg_stop="launchctl unload ~/Library/LaunchAgents"
现在,您应该可以使用pg_start
启动 PostgreSQL server,或者使用pg_stop
停止它。
在此之后,您需要创建第一个数据库,如下所示:
createdb `whoami`
现在,您可以使用psql
命令连接到 PostgreSQL。
如果您得到一个说明role "postgresql" does not exist
的错误,您可以通过运行以下命令来修复它:
createuser -s postgres
如果你做的每件事都是正确的,你会看到这样的情况:
If you use Windows, you can download PostgreSQL at https://www.postgresql.org/download/windows/ and for those that use Linux (Ubuntu), you can download it from https://www.postgresql.org/download/linux/ubuntu/.
PostgreSQL 数据库管理的最佳工具
PostgreSQL 数据库管理的最佳工具之一是pgAdmin 4(https://www.pgadmin.org/download/ )。我喜欢这个工具,因为它可以用来创建新的服务器、用户和数据库。我喜欢用来执行 SQL 查询和处理数据的另一个工具是OmniDB(https://omnidb.org 。我强烈建议您安装这两个工具。
记住创建一个数据库以便在本例中使用它。
Sometimes, you may get an error when you start your PostgreSQL server that could say something like
FATAL: lock file "postmaster.pid" already exists
.
If you get this error, you can easily fix it by running the rm /usr/local/var/postgres/postmaster.pid
command. Then, you will be able to start your PostgreSQL server.
创建.env 文件和配置文件
首先,您需要在 GraphQL 项目(graphql/backend
中创建一个后端目录,然后让我们查看需要安装的大量 NPM 软件包(最相关的):
npm init --yes
npm install @contentpi/lib @graphql-tools/load-files @graphql-tools/merge apollo-server dotenv express jsonwebtoken pg pg-hstore sequelize ts-node
npm install --save-dev husky jest prettier sequelize-mock ts-jest ts-node-dev typescript eslint @types/jsonwebtoken
package.json
文件中应该包含的脚本如下:
"scripts": {
"dev": "ts-node-dev src/index.ts",
"start": "ts-node dist/index.js",
"build": "tsc -p .",
"lint": "eslint . --ext .js,.tsx,.ts",
"lint:fix": "eslint . --fix --ext .js,.tsx,.ts",
"test": "jest src"
}
在下一节中,我们将配置环境变量。
配置我们的.env 文件
.env
文件(也称为dotenv
文件)是用于指定应用程序环境变量的配置文件。通常,您的应用程序不会在开发、登台或生产环境中更改,但它们通常需要不同的配置:最常见的更改变量是基本 URL、API URL,甚至是 API 键。
在我们跳转到实际的登录代码之前,我们需要创建一个名为.env
的文件(通常,该文件被.gitignore
忽略),这将允许我们使用私有数据,例如数据库连接和安全机密。存储库中已存在名为.env.example
的文件;您只需要重命名它并将连接数据放入其中。这将看起来像这样:
DB_DIALECT=postgres
DB_PORT=5432
DB_HOST=localhost
DB_DATABASE=<your-database>
DB_USERNAME=<your-username>
DB_PASSWORD=<your-password>
创建基本配置文件
对于这个项目,我们需要创建一个配置文件,应该在/backend/config/config.json
处创建。在这里,我们将定义一些基本配置,例如服务器的端口和一些安全信息:
{
"server": {
"port": 5000
},
"security": {
"secretKey": "C0nt3ntP1",
"expiresIn": "7d"
}
}
然后,您需要创建一个index.ts
文件。这将引入我们使用dotenv
包在.env
文件中定义的所有数据库连接信息,然后导出三个配置变量$db
、$security
和$server
:
// Dependencies
import dotenv from 'dotenv'
// Configuration
import config from './config.json'
// Loading .env vars
dotenv.config()
// Types
type Db = {
dialect: string
host: string
port: string
database: string
username: string
password: string
}
type Security = {
secretKey: string
expiresIn: string
}
type Server = {
port: number
}
// Extracting data from .env file
const {
DB_DIALECT = '',
DB_PORT = '',
DB_HOST = '',
DB_DATABASE = '',
DB_USERNAME = '',
DB_PASSWORD = '',
} = process.env
const db: Db = {
dialect: DB_DIALECT,
port: DB_PORT,
host: DB_HOST,
database: DB_DATABASE,
username: DB_USERNAME,
password: DB_PASSWORD
}
// Configuration
const { security, server } = config
export const $db: Db = db
export const $security: Security = security
export const $server: Server = server
如果您的.env
文件不在根目录下或不存在,则所有变量都将是undefined
。
配置 Apollo 服务器
Apollo Server 是最流行的与 GraphQL(服务器和客户端)一起使用的开源库。它有很多文档,非常容易实现。
下图说明了 Apollo Server 在客户端和服务器中的工作方式:
我们将使用 Express 设置 Apollo 服务器,并对 ORM 进行续集,以处理 PostgreSQL 数据库。所以,首先,我们需要做一些进口。所需文件可在/backend/src/index.ts
找到:
// Dependencies
import { ApolloServer, makeExecutableSchema } from 'apollo-server'
// Models
import models from './models'
// Type Definitions & Resolvers
import resolvers from './graphql/resolvers'
import typeDefs from './graphql/types'
// Configuration
import { $server } from '../config'
首先,我们需要通过传递typeDefs
和resolvers
来使用makeExecutableSchema
创建我们的模式:
// Schema
const schema = makeExecutableSchema({
typeDefs,
resolvers
})
然后,我们需要创建一个ApolloServer
实例,在这里我们需要在上下文中传递模式和模型:
// Apollo Server
const apolloServer = new ApolloServer({
schema,
context: {
models
}
})
最后,我们需要同步 Sequelize。这里,我们传递一些可选变量(alter
和force
。如果force
是true
并且您更改了 Sequelize 模型,这将删除您的表,包括它们的值,并强制您重新创建表,而如果force
是false
并且alter
是true
,则只会更新表字段,而不会影响您的值。因此,您需要小心使用此选项,因为您可能会意外丢失所有数据。然后,在同步之后,我们必须运行阿波罗服务器,它正在监听端口5000
($server.port
):
const alter = true
const force = false
models.sequelize.sync({ alter, force }).then(() => {
apolloServer
.listen($server.port)
.then(({ url }) => {
// eslint-disable-next-line no-console
console.log(`Running on ${url}`)
})
})
这将帮助我们将数据库与模型同步,以便在任何时候对模型进行更改时,都会更新表。
定义 GraphQL 类型、查询和变体
既然已经创建了 Apollo 服务器实例,就需要创建 GraphQL 类型。在本例中,我们将为用户创建一些类型、查询和变体。
您需要做的第一件事是在/backend/src/graphql/types/Scalar.graphql
处定义标量类型:
scalar UUID
scalar Datetime
scalar JSON
现在,让我们用初始的User
类型创建User.graphql
文件:
type User {
id: UUID!
username: String!
password: String!
email: String!
privilege: String!
active: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
}
如您所见,我们正在使用一些标量类型,如UUID
和Datetime
来定义User
类型中的一些字段。在本例中,当您在 GraphQL 中定义类型时,需要使用type
关键字,后跟大写的类型名称。然后,您可以在花括号{}
中定义字段。
GraphQL 中有一些原始数据类型,如String
、Boolean
、Float
和Int
。您可以像我们对UUID
、Datetime
和JSON
那样定义自定义标量类型,也可以定义自定义类型,如User
类型,并指定我们是否需要该类型的数组;例如,[User]
。
The !
character after the types means the field is non-nullable.
询问
GraphQL 查询用于从数据存储中读取或获取值。
既然您已经知道如何定义自定义类型,那么让我们来定义Query
类型。在这里,我们将定义getUsers
和getUserData
。第一个将检索用户列表,第二个将为我们提供特定用户的数据:
type Query {
getUsers: [User!]
getUserData(at: String!): User!
}
在这种情况下,我们的getUsers
查询将返回一个用户数组([User!]
,而我们的getUserData
查询需要at
(访问令牌属性),将返回一个User!
。请记住,对于在此处添加的任何查询,稍后需要在解析器下定义它(我们将在下一节中进行定义)。
突变
如果要与 REST 进行一些比较,例如执行任何 post、PUT 或 DELETE 操作,则可以使用突变来写入或发布值(即修改数据存储中的数据),并返回值。Mutation
类型的工作原理与Query
类型完全相同。您需要定义您的突变,并指定您将接收哪些参数和返回哪些数据:
type Mutation {
createUser(input: CreateUserInput): User!
login(input: LoginInput): AuthPayload!
}
如你所见,我们定义了两种突变。第一个是createUser
,在我们的数据存储中注册或创建一个新用户,而第二个是执行login
。正如您可能已经注意到的,这两个函数都接收到了具有不同值(CreateUserInput
和LoginInput
)的input
参数,称为输入类型,用作查询或突变参数。最后,它们将分别返回User!
类型和AuthPayload!
。让我们学习如何定义这些输入:
input CreateUserInput {
username: String!
password: String!
email: String!
privilege: String!
active: Boolean!
}
input LoginInput {
email: String!
password: String!
}
type AuthPayload {
token: String!
}
输入通常用于突变,但也可以用于查询。
合并我们的类型定义
现在我们已经定义了所有类型、查询和突变,我们需要合并所有 GraphQL 文件来创建 GraphQL 模式,它基本上是一个包含所有 GraphQL 定义的大文件。
为此,您需要创建一个名为/backend/src/graphql/types/index.ts
的文件,其中包含以下代码:
import path from 'path'
import { loadFilesSync } from '@graphql-tools/load-files'
import { mergeTypeDefs } from '@graphql-tools/merge'
const typesArray = loadFilesSync(path.join(__dirname, './'), { extensions: ['graphql'] })
export default mergeTypeDefs(typesArray)
我们正在使用@graphql-tools
包加载我们的 GraphQL 文件,并使用mergeTypesDefs
方法将它们合并到typesArray
。
创建我们的解析器
解析器是负责为 GraphQL 模式中的字段生成数据的函数。它通常可以以您想要的任何方式生成数据,因为它可以从数据库或使用第三方 API 获取数据。
要创建我们的用户解析器,您需要创建一个名为/backend/src/graphql/resolvers/user.ts
的文件。让我们创建一个分解器应该是什么样子的框架。这里,我们需要在 GraphQL 模式中指定在Query
和Mutation
下定义的函数。因此,您的解析器应如下所示:
export default {
Query: {
getUsers: () => {},
getUserData: () => {},
},
Mutation: {
createUser: () => {},
login: () => {}
}
}
如您所见,我们正在返回一个包含两个主要节点的对象,分别称为Query
和Mutation
,我们正在映射我们在 GraphQL 模式(即User.graphql
文件)中定义的查询和突变。当然,我们需要进行一些更改以接收一些参数并返回一些数据,但我想首先向您展示解析器文件的基本框架。
您需要做的第一件事是向文件添加一些导入:
// Lib
import { getUserData } from '../../lib/jwt'
// Interfaces
import {
IUser,
ICreateUserInput,
IModels,
ILoginInput,
IAuthPayload
} from '../../types'
// Utils
import { doLogin, getUserBy } from '../../lib/auth'
我们将在下一节中创建doLogin
和getUserBy
函数。
创建 getUsers 查询
我们的第一种方法是getUsers
查询。让我们看看我们需要如何定义它:
getUsers: (
_: any,
args: any,
ctx: { models: IModels }
): IUser[] => ctx.models.User.findAll(),
在任何查询或变异方法中,我们都会收到四个参数:父参数(定义为)、参数(定义为args
)、上下文(定义为ctx
)和info
(可选)。
**如果要稍微简化代码,可以对上下文进行分解,如下所示:
getUsers: (
_: any,
args: any,
{ models }: { models: IModels }
): IUser[] => models.User.findAll(),
在下一个解析器函数中,我们还将对参数进行分解。作为提醒,上下文正在我们的 Apollo 服务器设置中传递(我们之前已经这样做了):
// Apollo Server
const apolloServer = new ApolloServer({
schema,
context: {
models
}
})
当我们需要在解析程序中全局共享某些内容时,上下文非常重要。
创建 getUserData 查询
此函数需要异步,因为我们需要执行一些异步操作,例如,如果用户已经拥有有效会话,则通过at
(访问令牌)获取连接的用户。然后,我们可以通过查看数据库来验证这是否是真正的用户。这有助于阻止人们修改 cookies 或尝试进行某种形式的注射。如果找不到连接的用户,则返回包含空数据的用户对象:
getUserData: async (
_: any,
{ at }: { at: string },
{ models }: { models: IModels }
): Promise<any> => {
// Get current connected user
const connectedUser = await getUserData(at)
if (connectedUser) {
// Validating if the user is still valid
const user = await getUserBy(
{
id: connectedUser.id,
email: connectedUser.email,
privilege: connectedUser.privilege,
active: connectedUser.active
},
models
)
if (user) {
return connectedUser
}
}
return {
id: '',
username: '',
password: '',
email: '',
privilege: '',
active: false
}
}
创造突变
我们的突变非常简单——我们只需要执行一些函数,并通过传播输入值(这来自我们的 GraphQL 模式)来传递所有参数。让我们看看我们的Mutation
节点应该是什么样子:
Mutation: {
createUser: (
_: any,
{ input }: { input: ICreateUserInput },
{ models }: { models: IModels }
): IUser => models.User.create({ ...input }),
login: (
_: any,
{ input }: { input: ILoginInput },
{ models }: { models: IModels }
): Promise<IAuthPayload> => doLogin(input.email, input.password, models)
}
您需要将电子邮件、密码和型号传递给doLogin
功能。
合并我们的解析器
正如我们对类型定义所做的那样,我们需要使用@graphql-tools
包合并所有解析程序。您需要在/backend/src/graphql/resolvers/index.ts
创建以下文件:
import path from 'path'
import { loadFilesSync } from '@graphql-tools/load-files'
import { mergeResolvers } from '@graphql-tools/merge'
const resolversArray = loadFilesSync(path.join(__dirname, './'))
const resolvers = mergeResolvers(resolversArray)
export default resolvers
这将把所有的解析器组合成一个解析器数组。
创建续集模型
在开始验证功能之前,我们需要在 Sequelize 中创建User
模型。为此,我们需要在/backend/src/models/User.ts
处创建一个文件。我们的模型将包含以下字段:
id
username
password
email
privilege
active
让我们看看代码:
// Dependencies
import { encrypt } from '@contentpi/lib'
// Interfaces
import { IUser, IDataTypes } from '../types'
export default (sequelize: any, DataTypes: IDataTypes): IUser => {
const User = sequelize.define(
'User',
{
id: {
primaryKey: true,
allowNull: false,
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4()
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isAlphanumeric: {
args: true,
msg: 'The user just accepts alphanumeric characters'
},
len: {
args: [4, 20],
msg: 'The username must be from 4 to 20 characters'
}
}
},
password: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: {
args: true,
msg: 'Invalid email'
}
}
},
privilege: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'user'
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{
hooks: {
beforeCreate: (user: IUser): void => {
user.password = encrypt(user.password)
}
}
}
)
return User
}
As you can see, we are defining a Sequelize Hook called beforeCreate
, which helps us encrypt (using sha1
) the user password right before the data is saved. Finally, we return the User
model.
Connecting Sequelize to a PostgreSQL database
Now that we've created the user model, we need to connect Sequelize to our PostgreSQL database and put all our models together. You need to add the following code to the /backend/src/models/index.ts
file:
// Dependencies
import { Sequelize } from 'sequelize'
// Configuration
import { $db } from '../../config'
// Interfaces
import { IModels } from '../types'
// Db Connection
const { dialect, port, host, database, username, password } = $db
// Connecting to the database
const uri = `${dialect}://${username}:${password}@${host}:${port}/${database}`
const sequelize = new Sequelize(uri)
// Models
const models: IModels = {
User: require('./User').default(sequelize, Sequelize),
sequelize
}
export default models
Authentication functions
我们正在一步一步地把所有的拼图拼在一起。现在,让我们看一下用于验证用户是否连接并获取用户数据的身份验证函数。为此,我们需要使用JSON Web 令牌(JWTs)。
什么是 JSON Web 令牌?
JWT是一个开放标准——RFC 7519(https://tools.ietf.org/html/rfc7519 –用于在各方之间作为 JSON 对象传输信息。JWT 的优点是它们是数字签名的,这就是为什么它们可以被验证和信任的原因。它使用 HMAC 算法通过使用 RSA 或 ECDSA 的密钥或公钥对对令牌进行签名。
JWT 函数
让我们创建一些函数来帮助验证 JWT 并获取用户数据。为此,我们需要创建jwtVerify
、getUserData
和createToken
函数。此文件应在/backend/src/lib/jwt.ts
处创建:
// Dependencies
import jwt from 'jsonwebtoken'
import { encrypt, setBase64, getBase64 } from '@contentpi/lib'
// Configuration
import { $security } from '../../config'
// Interface
import { IUser } from '../types'
const { secretKey } = $security
export function jwtVerify(accessToken: string, cb: any): void {
// Verifiying our JWT token using the accessToken and the secretKey
jwt.verify(
accessToken,
secretKey,
(error: any, accessTokenData: any = {}) => {
const { data: user } = accessTokenData
// If we get an error or the user is not found we return false
if (error || !user) {
return cb(false)
}
// The user data is on base64 and getBase64 will retreive the
// information as JSON object
const userData = getBase64(user)
return cb(userData)
}
)
}
export async function getUserData(accessToken: string): Promise<any> {
// We resolve the jwtVerify promise to get the user data
const UserPromise = new Promise(resolve =>
jwtVerify(accessToken, (user: any) => resolve(user))
)
// This will get the user data or false (if the user is not connected)
const user = await UserPromise
return user
}
export const createToken = async (user: IUser): Promise<string[]> => {
// Extracting the user data
const { id, username, password, email, privilege, active } = user
// Encrypting our password by combining the secretKey and the password
// and converting it to base64
const token = setBase64(`${encrypt($security.secretKey)}${password}`)
// The "token" is an alias for password in this case
const userData = {
id,
username,
email,
privilege,
active,
token
}
// We sign our JWT token and we save the data as Base64
const _createToken = jwt.sign(
{ data: setBase64(userData) },
$security.secretKey,
{ expiresIn: $security.expiresIn }
)
return Promise.all([_createToken])
}
如您所见,jwt.sign
用于创建新的 JWT,而jwt.verify
用于验证我们的 JWT。
创建身份验证函数
现在我们已经创建了 JWT 函数,我们需要创建一些函数来帮助我们登录到/backend/src/lib/auth.ts
:
// Dependencies
import { AuthenticationError } from 'apollo-server'
// Utils
import { encrypt, isPasswordMatch } from '@contentpi/lib'
// Interface
import { IUser, IModels, IAuthPayload } from '../types'
// JWT
import { createToken } from './jwt'
export const getUserBy = async (
where: any,
models: IModels
): Promise<IUser> => {
// We find a user by a WHERE condition
const user = await models.User.findOne({
where,
raw: true
})
return user
}
export const doLogin = async (
email: string,
password: string,
models: IModels
): Promise<IAuthPayload> => {
// Finding a user by email
const user = await getUserBy({ email }, models)
// If the user does not exists we return Invalid Login
if (!user) {
throw new AuthenticationError('Invalid Login')
}
// We verify that our encrypted password is the same as the user.password
// value
const passwordMatch = isPasswordMatch(encrypt(password), user.password)
// We validate that the user is active
const isActive = user.active
// If the password does not match we return invalid login
if (!passwordMatch) {
throw new AuthenticationError('Invalid Login')
}
// If the account is not active we return an error
if (!isActive) {
throw new AuthenticationError('Your account is not activated yet')
}
// If the user exists, the password is correct and the account is active
// then we create the JWT token
const [token] = await createToken(user)
// Finally we return the token to Graphql
return {
token
}
}
在这里,我们通过电子邮件验证用户是否存在,密码是否正确,以及帐户是否处于活动状态,以便创建 JWT。
类型和接口
最后,我们需要为所有 Sequelize 模型和 GraphQL 输入定义类型和接口。为此,您需要在/backend/src/types/types.ts
处创建一个文件:
export type User = {
username: string
password: string
email: string
privilege: string
active: boolean
}
export type Sequelize = {
_defaults?: any
name?: string
options?: any
associate?: any
}
现在,让我们在/backend/src/types/interfaces.ts
创建我们的接口:
// Types
import { User, Sequelize } from './types'
// Sequelize
export interface IDataTypes {
UUID: string
UUIDV4(): string
STRING: string
BOOLEAN: boolean
TEXT: string
INTEGER: number
DATE: string
FLOAT: number
}
// User
export interface IUser extends User, Sequelize {
id: string
token?: string
createdAt?: Date
updatedAt?: Date
}
export interface ICreateUserInput extends User {}
export interface ILoginInput {
email: string
password: string
}
export interface IAuthPayload {
token: string
}
// Models
export interface IModels {
User: any
sequelize: any
}
最后,我们需要导出/backend/src/types/index.ts
中的两个文件:
export * from './interfaces'
export * from './types'
当您需要添加更多模型时,请记住始终将您的类型和接口添加到这些文件中。
最后,您需要在根目录下创建您的tsconfig.json
文件:
{
"compilerOptions": {
"baseUrl": "./src",
"esModuleInterop": true,
"module": "commonjs",
"noImplicitAny": true,
"outDir": "dist",
"resolveJsonModule": true,
"sourceMap": true,
"target": "es6",
"typeRoots": ["./src/@types", "./node_modules/@types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
在下一节中,我们将运行项目并创建表。
第一次运行我们的项目
如果您正确地遵循前面的部分并运行npm run dev
命令,您应该能够看到正在创建Users
表,并且阿波罗服务器正在端口5000
上运行:
现在,假设您想修改您的用户模型,并将"username"
字段更改为"username2"
。让我们看看会发生什么:
[INFO] 23:45:16 Restarting: /Users/czantany/projects/React-Design-Patterns-and-Best-Practices-Third-Edition/Chapter05/graphql/backend/src/models/User.ts has been modified
Executing (default): CREATE TABLE IF NOT EXISTS "Users" ("id" UUID NOT NULL , "username2" VARCHAR(255) NOT NULL UNIQUE, "password" VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL UNIQUE, "privilege" VARCHAR(255) NOT NULL DEFAULT 'user', "active" BOOLEAN NOT NULL DEFAULT false, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));
Executing (default): ALTER TABLE "public"."Users" ADD COLUMN "username2" VARCHAR(255) NOT NULL UNIQUE;
Executing (default): ALTER TABLE "Users" ALTER COLUMN "password" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "password" DROP DEFAULT;ALTER TABLE "Users" ALTER COLUMN "password" TYPE VARCHAR(255);
Executing (default): ALTER TABLE "Users" ALTER COLUMN "email" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "email" DROP DEFAULT;ALTER TABLE "Users" ADD UNIQUE ("email");ALTER TABLE "Users" ALTER COLUMN "email" TYPE VARCHAR(255) ;
Executing (default): ALTER TABLE "Users" ALTER COLUMN "privilege" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "privilege" SET DEFAULT 'user';ALTER TABLE "Users" ALTER COLUMN "privilege" TYPE VARCHAR(255);
Executing (default): ALTER TABLE "Users" ALTER COLUMN "active" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "active" SET DEFAULT false;ALTER TABLE "Users" ALTER COLUMN "active" TYPE BOOLEAN;
Executing (default): ALTER TABLE "Users" ALTER COLUMN "createdAt" SET NOT NULL;ALTER TABLE "Users" ALTER COLUMN "createdAt" DROP DEFAULT;ALTER TABLE "Users" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE;
Running on http://localhost:5000/
这将执行以下 SQL 查询:
Executing (default): ALTER TABLE "public"."Users" ADD COLUMN "username2" VARCHAR(255) NOT NULL UNIQUE;
Executing (default): ALTER TABLE "public"."Users" DROP COLUMN "username";
现在,假设您将index.ts
文件中的force
常量更改为true
。将发生以下情况:
如您所见,如果force
是true
,它将执行DROP TABLE IF EXISTS "Users" CASCADE;
。这将完全删除表和值,然后从头开始重新创建表。这就是为什么在使用force
选项时需要小心的原因。
此时,如果您打开http://localhost:5000
,您应该能够看到您的 GraphQL 游乐场:
现在,我们已经准备好测试我们的查询和变异。
测试 GraphQL 查询和变异
伟大的此时,您就快要执行第一个 GraphQL 查询和变异了。我们将执行的第一个查询是getUsers
。以下是运行查询的正确语法:
query {
getUsers {
id
username
email
privilege
}
}
当您没有任何属性要传递给查询时,只需在query {...}
块下指定查询的名称,然后指定执行查询后要检索的字段。在本例中,我们希望获取id
、username
、email
和privilege
字段。
如果运行此查询,可能会得到一个空的数据数组。这是因为我们尚未注册任何用户:
这意味着我们需要执行createUser
变异以注册第一个用户。关于 GraphQL,我喜欢的一点是,所有的模式文档都在右边的 DOCS 选项卡中。如果单击“文档”选项卡,您将看到列出的所有查询和更改。让我们点击这里并选择我们的createUser
突变,看看需要调用什么以及可能返回什么数据:
如您所见,createUser
突变需要一个输入参数,即CreateUserInput
。让我们单击该输入:
令人惊叹的现在,我们知道我们需要传递username
、password
、email
、privilege
和active
字段来创建一个新用户,并且我们将为该用户接收相同的字段以及生成的 ID。让我们这样做!
创建一个新选项卡,这样您就不会丢失第一个查询的代码,然后编写代码:
mutation {
createUser(
input: {
username: "admin",
email: "admin@js.education",
password: "123456",
privilege: "god",
active: true
}
) {
id
username
email
password
privilege
}
}
如您所见,您的变异需要写在mutation {...}
块下,并且必须将输入参数作为对象传递。最后,必须指定正确执行变异后要检索的字段。如果一切正常,您应该看到如下内容:
如果您很好奇,并希望查看正在运行 Apollo Server 的终端,您将看到为该用户执行的 SQL 查询:
Executing (default): INSERT INTO "Users" ("id","username","password","email","privilege","active","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id","username","password","email","privilege","active","createdAt","updatedAt";
VALUES
变量由 Apollo Server 处理,因此您不会看到其中的实际值,但您可以找到数据库中正在执行的操作。
现在,返回到您的第一个查询(getUsers
并再次运行它!
Nice–这是您在 GraphQL 中正确执行的第一个查询和变异。如果您想在数据库中查看此数据,可以使用 OmniDB 查看 PostgreSQL 数据库中的Users
表:
如您所见,我们的第一条记录有自己的id
字段(UUID),还有一个加密的password
字段(您还记得用户模型中的beforeCreate
钩子吗?)。默认情况下,Sequelize 将创建createdAt
和updatedAt
字段。
验证
您可能还记得,关于我们的用户模型,您需要确保我们所做的所有验证都正常工作,例如用户是否唯一,或者他们的电子邮件是否有效且唯一。您只需再次执行完全相同的变异:
如您所见,我们将收到一条"username must be unique"
错误消息,因为我们已经注册了"admin"
用户名。现在,让我们尝试将用户名更改为"admin2"
,但保留电子邮件原样(admin@js.education
:
我们还将收到一封电子邮件的"email must be unique"
错误。现在,尝试将电子邮件更改为无效的内容,例如admin@myfakedomain
:
现在,我们收到一条"Invalid email"
错误消息。这太神奇了,你不觉得吗?现在,让我们停止使用验证,添加一个新的有效用户(username: admin2
、email: admin2@js.education
。创建第二个用户后,再次运行我们的getUsers
查询。但是,这一次,将"active"
字段添加到我们要返回的字段列表中:
现在,我们有两个注册用户,都是非活动帐户(active = false
。
我喜欢 GraphQL 的一点是,当您编写查询或突变时,如果您不记得某个字段,GraphQL 将始终显示该查询或突变的可用字段列表。例如,如果您只写字母p
作为密码,您将看到如下内容:
现在,我们已准备好尝试登录!
执行登录
我想祝贺你在这本书中走到了这一步——我知道我们已经涵盖了很多,但我们已经差不多做到了!现在,我们将尝试使用 GraphQL 登录(这有多疯狂?)。
首先,我们需要编写登录代码:
mutation {
login(
input: {
email: "fake@email.com",
password: "123456"
}
) {
token
}
}
然后,我们需要使用"fake@email.com"
作为我们的电子邮件,"123456"
作为我们的密码登录我们的用户。这些在我们的数据库中不存在:
由于我们的数据库中不存在该电子邮件,因此将返回一条"Invalid Login"
错误消息。现在,让我们添加正确的电子邮件,但使用假密码:
如您所见,我们收到了完全相同的错误("Invalid Login"
。这是因为我们不想提供太多关于登录错误的信息,因为有人可能试图攻击其他用户。如果我们说了诸如"Invalid password"
或"Your email does not exist in our system"
之类的话,我们就给了攻击者额外的信息,他们可能会觉得有用。
现在,让我们尝试连接正确的用户和密码(admin@js.education / 123456
,看看会发生什么:
现在,我们收到一个错误,说明"Your account is not activated yet"
。这是正常的,因为我们的用户还没有被激活。通常,当用户在系统中注册时,您需要向其电子邮件发送链接,以便他们可以激活其帐户。我们目前没有这个功能,但是假设我们发送了那封电子邮件,用户已经激活了他们的帐户。我们可以通过使用 OnmiDB 手动更改数据库中的值来模拟这种情况。我们可以通过执行UPDATE
SQL 查询来实现这一点:
现在,让我们再次尝试登录!
很好-我们在宝贝!此时您:
我们是匿名的,我们是军团,我们不会原谅,我们不会忘记,期待我们!
现在我们已经登录并检索了 JWT,让我们复制这个巨大的字符串,并在我们的getUserData
查询中使用它,看看我们是否可以获取用户的数据:
query {
getUserData(at: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoiZXlKcFpDSTZJalEzTnpsaU16QTJMV1U0TW1NdE5HVmtNUzFoWldNM0xXSXdaVEl5TWpSaU5UUTNaU0lzSW5WelpYSnVZVzFsSWpvaVlXUnRhVzRpTENKbGJXRnBiQ0k2SW1Ga2JXbHVRR3B6TG1Wa2RXTmhkR2x2YmlJc0luQnlhWFpwYkdWblpTSTZJbWR2WkNJc0ltRmpkR2wyWlNJNmRISjFaU3dpZEc5clpXNGlPaUpOUkdjeldWUkZNMXBVWjNwTmJWVjVXV3BWTWs1SFJtMWFiVTB6V21wTk5GbFVRWGxhVkdSb1RVUm9iVTFIVlROTmJWa3dXVlJrYWs1SFJUUmFSRUUxV1RKRmVrNTZXWGxaVjFreVRWZFZNVTlVVlhsTlJHc3dUVEpTYWsxcVdUQlBWRkp0VDBSck1FMVhTVDBpZlE9PSIsImlhdCI6MTYxNzY5ODY4OSwiZXhwIjoxNjE4MzAzNDg5fQ.6icaBFibjEOICUt5QQ0OPAoDsb7_ohb8W10JzHnbf7k") {
id
email
privilege
active
}
}
如果一切顺利,那么您应该获得用户的数据:
如果更改或删除字符串中的任何字母(表示令牌无效),则应获取空用户数据:
既然我们的登录系统在后端工作得很好,现在是在前端应用程序中实现这一点的时候了。我们将在下一节中进行此操作。
使用 Apollo 客户端构建前端登录系统
在上一节中,我们学习了如何使用 Apollo Server 为登录系统构建后端,以创建 GraphQL 查询和变体。你可能会想,太好了,我的后端工作正常,但我如何在前端使用它?你是对的——我总是喜欢用完整的例子来解释事情,而不仅仅是展示基本的东西,即使这需要更长的时间,所以让我们开始吧!
配置网页包 5
我们将使用 Webpack 5 和 Node 从头开始配置 React 项目,而不是使用create-react-app
项目。
我们需要做的第一件事是安装我们将要使用的所有软件包:
npm init --yes
npm install @apollo/client @contentpi/lib cookie-parser cors express express-session jsonwebtoken react react-dom react-cookie react-router-dom styled-components
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react buffer cross-env crypto-browserify dotenv prettier stream-browserify ts-loader ts-node ts-node-dev typescript webpack webpack-cli webpack-dev-server html-webpack-plugin
缓冲区crypto-browserify
和stream-browserify
是默认包含在网页包<=4 中的多填充。但是,在最新版本(Webpack 5)中,不再包含这些内容,因此您将收到以下错误:
您的package.json
中需要有这些脚本:
"scripts": {
"start": "ts-node src/server",
"dev": "ts-node-dev src/server",
"webpack": "cross-env NODE_ENV=development webpack serve --mode development",
"build": "cross-env NODE_ENV=production webpack --mode production",
"clean": "rimraf dist/ && rimraf public/app",
"lint": "eslint . --ext .js,.tsx,.ts",
"lint:fix": "eslint . --fix --ext .js,.tsx,.ts",
"test": "jest src",
"test:coverage": "jest src --coverage"
}
我们来看看我们的 Webpack 5 配置文件(/frontend/webpack.config.ts
:
// Dependencies
import path from 'path'
import webpack, { Configuration } from 'webpack'
import HtmlWebPackPlugin from 'html-webpack-plugin'
// Environment
const isProduction = process.env.NODE_ENV === 'production'
const webpackConfig: Configuration = {
devtool: !isProduction ? 'source-map' : false,
target: 'web',
mode: isProduction ? 'production' : 'development',
entry: './src/index.tsx',
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js',
publicPath: '/'
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
fallback: { // This is to fix the polifylls errors
buffer: require.resolve('buffer'),
crypto: require.resolve("crypto-browserify"),
stream: require.resolve("stream-browserify")
}
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true
}
},
exclude: /node_modules/
}
]
},
optimization: {
splitChunks: { // This will split our bundles into vendor.js and
// main.js
cacheGroups: {
default: false,
commons: {
test: /node_modules/,
name: 'vendor',
chunks: 'all'
}
}
}
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new HtmlWebPackPlugin({
template: './src/index.html',
filename: './index.html',
publicPath: !isProduction ? 'http://localhost:8080/' : '' // For dev
// we will read the bundle from localhost:8080 (webpack-dev-server)
})
]
}
export default webpackConfig
此时,您需要创建index.html
文件,该文件应位于/frontend/src/index.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1,
maximum-scale=1" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Login System</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
在下一节中,我们将配置 TypeScript。
配置我们的打字脚本
我们的tsconfig.json
文件应该如下所示:
{
"compilerOptions": {
"sourceMap": true,
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "commonjs",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noImplicitAny": false,
"types": ["node", "express"]
},
"include": ["src"]
}
现在,让我们学习如何配置 Express 服务器。
配置 Express 服务器
我们的应用程序需要 Express 服务器,以便执行验证。这些将帮助我们发现用户是否已连接(使用自定义中间件,我将在后面解释),并且还可以配置我们的 Express 会话。我们的网站上有四条主要路线:
/
:我们的主页(React 处理)。/dashboard
:我们的仪表盘,受保护。仅允许具有 god 或 admin 权限的连接用户(先由 Express 处理,然后由 React 处理)。/login
:我们的登录页面(React 处理)。/logout
:这将删除我们现有的会话(由 Express 处理)。
让我们看看我们的服务器代码。以下文件应存在于/frontend/src/server.ts
:
// Dependencies
import express, { Request, Response, NextFunction } from 'express'
import path from 'path'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import session from 'express-session'
// Middleware
import { isConnected } from './lib/middlewares/user'
// Config
import config from './config'
// Express app
const app = express();
const port = process.env.NODE_PORT || 3000
const DIST_DIR = path.join(__dirname, '../dist')
const HTML_FILE = path.join(DIST_DIR, 'index.html')
// Making the dist directory static
app.use(express.static(DIST_DIR));
// Middlewares
app.use(
session({
resave: false,
saveUninitialized: true,
secret: config.security.secretKey
})
)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(config.security.secretKey))
app.use(cors({ credentials: true, origin: true }))
// Routes
app.get('/dashboard',
isConnected(
true,
['god', 'admin'], // Those are the allowed permissions
`/login?redirectTo=/dashboard` // If the user is not allowed will be
// redirect to this path
),
(req: Request, res: Response, next: NextFunction) => {
// If the user isConnected then we allow the access to the dashboard
// page otherwise will be redirect to /login
next()
}
)
// Forcing only No connected users to access to /login, if a connected user
// try to access will be redirect to the homepage
app.get('/login', isConnected(false), (req: Request, res: Response, next: NextFunction) => {
next()
})
app.get(`/logout`, (req: Request, res: Response) => {
// This will cler our "at" cookie and redirect to home
res.clearCookie('at')
res.redirect('/')
})
app.get('*', (req: Request, res: Response) => {
// We render our React application
res.sendFile(HTML_FILE)
})
// Listening
app.listen(port, () => console.log(`Running at http://localhost:${port}`))
如您所见,我们正在使用isConnected
中间件保护仪表板路由。这里,我们正在验证我们只接受在login
路由中未连接的用户。
创建前端配置
现在,我们需要创建前端配置。那么,让我们在/frontend/src/config/common.json
处创建common.json
配置:
{
"server": {
"port": 3000
},
"security": {
"secretKey": "C0nt3ntP1", // This needs to be the same as the backend
// secretKey
"expiresIn": "7d"
}
}
现在,让我们创建local.json
文件:
{
"baseUrl": "http://localhost:3000",
"apiUrl": "http://localhost:5000/graphql"
}
现在,我们需要创建我们的production.json
文件;目前,由于我们没有实际的生产环境,我们将使用相同的 localhost URL,但一旦您将此项目置于生产环境中,则需要将其更改为实际域名:
{
"baseUrl": "http://localhost:3000",
"apiUrl": "http://localhost:5000/graphql"
}
现在我们已经定义了配置文件,我们需要创建一个index.ts
文件,这样我们就可以将配置合并并导出为一个对象:
// Configuration
import common from './common.json'
import local from './local.json'
import production from './production.json'
// Interface
interface IConfig {
baseUrl: string
apiUrl: string
server: {
port: number
}
security: {
secretKey: string
expiresIn: string
}
}
const { NODE_ENV = 'development' } = process.env
// development => local
let environment = 'local'
if (NODE_ENV !== 'development') {
environment = NODE_ENV
}
// Configurations by environment
const config: IConfig = {
...common,
...(environment === 'local' ? local : production)
}
// Environments validations
export const isLocal = () => environment === 'local'
export const isProduction = () => environment === 'production'
export default config
现在,我们需要创建一个名为middleware
的用户和jwt
函数,以验证该用户是否已连接并具有正确的权限。
创建用户中间件
中间件是可以访问请求对象(req)、响应对象(res)和应用程序请求-响应周期中的下一个函数的函数。下一个函数是 Express router 中的一个函数,当调用该函数时,将执行继当前中间件之后的中间件。下图描述了中间件流程:
在本例中,我们将创建isConnected
中间件,以验证用户是否已连接并具有正确的权限。如果没有,那么我们将中断流并将其重定向到登录页面。如果用户有效,我们将执行下一个中间件,它将呈现我们的 React 应用程序。
下图描述了此过程:
让我们将理论部分应用到代码中。所需文件应存在于/frontend/src/lib/middlewares/user.ts
:
// Dependencies
import { Request, Response, NextFunction } from 'express'
// Lib
import { getUserData } from '../jwt'
export const isConnected = (isLogged = true, privileges = ['user'], redirectTo = '/') => async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
// Getting the user information by passing our 'at' cookie
const user = await getUserData(req.cookies.at)
if (!user && !isLogged) {
// This is to allow No connected users
return next()
}
// Allowing just connected users and validating privileges...
if (user && isLogged) {
// If the user is connected and is god...
if (privileges.includes('god') && user.privilege === 'god') {
return next()
}
// If the user is conencted and is admin...
if (privileges.includes('admin') && user.privilege === 'admin') {
return next()
}
// If the user is connected but is not god or admin.
res.redirect(redirectTo)
} else {
// If the user is not connected
res.redirect(redirectTo)
}
}
基本上,通过这个中间件,我们可以控制是否要验证用户是否已连接(isLogged = true
。然后,我们可以验证特定权限(privileges = ['god', 'admin']
),并在用户未连接或没有正确权限(redirectTo = '/'
时重定向用户。
如您所见,我们正在使用jwt
中的getUserData
函数。我们将在下一节中创建jwt
函数。
创建 JWT 函数
在上一节中,当我解释后端代码时,我谈到了 JWTs。在前端,我们需要这些函数来验证令牌并获取用户的数据。让我们在/frontend/src/lib/jwt.ts
处创建一个包含以下代码的文件:
// Dependencies
import jwt from 'jsonwebtoken'
import { getBase64 } from '@contentpi/lib'
// Configuration
import config from '../config'
// Getting our secretKey
const {
security: { secretKey }
} = config
export function jwtVerify(accessToken: any, cb: any): void {
// Validating our accessToken
jwt.verify(accessToken, secretKey, (error: any, accessTokenData: any =
{}) => {
const { data: user } = accessTokenData
// If we got an error or the user is not connected we return false
if (error || !user) {
return cb(false)
}
// Getting the user data
const userData = getBase64(user)
return cb(userData)
})
}
export async function getUserData(accessToken: any): Promise<any> {
// This is an async function to retrieve the user data from the
// jwtVerify function
const UserPromise = new Promise(resolve => jwtVerify(accessToken, (user:
any) => resolve(user)))
const user = await UserPromise
return user
}
如您所见,我们的getUserData
函数将使用accessToken
检索用户数据,这是我们从 cookie 中获取的。重要的是 JWT 是有效的。
创建 GraphQL 查询和变体
我们已经在后端项目中创建了所需的查询和变体。此时,我们需要创建一些文件,以便在前端项目中执行它们。现在,我们只需要定义getUserData
查询和登录变异。
让我们在/frontend/src/graphql/user/getUserData.query.ts
创建getUserData
查询:
// Dependencies
import { gql } from '@apollo/client'
export default gql`
query getUserData($at: String!) {
getUserData(at: $at) {
id
email
username
privilege
active
}
}
`
我们的登录地址应为/frontend/src/graphql/user/login.mutation.ts
:
// Dependencies
import { gql } from '@apollo/client'
export default gql`
mutation login($email: String!, $password: String!) {
login(input: { email: $email, password: $password }) {
token
}
}
`
现在我们已经定义了查询和变异,让我们创建用户上下文以便使用它们。
创建用户上下文以处理登录和连接的用户
在我们的用户上下文中,我们将有一个登录方法,该方法将执行我们的变异,并验证电子邮件和密码是否正确。我们还将导出用户数据。
让我们在/frontend/src/contexts/user.tsx
处创建此上下文:
// Dependencies
import { FC, createContext, ReactElement, useState, useEffect } from 'react'
import { useCookies } from 'react-cookie'
import { getGraphQlError, redirectTo, getDebug } from '@contentpi/lib'
import { useQuery, useMutation } from '@apollo/client'
// Mutations
import LOGIN_MUTATION from '../graphql/user/login.mutation'
// Queries
import GET_USER_DATA_QUERY from '../graphql/user/getUserData.query'
// Interfaces
interface IUserContext {
login(input: any): any
connectedUser: any
}
interface IProps {
page?: string
children: ReactElement
}
// Creating context
export const UserContext = createContext<IUserContext>({
login: () => null,
connectedUser: null
})
const UserProvider: FC<IProps> = ({ page = '', children }): ReactElement => {
const [cookies, setCookie] = useCookies()
const [connectedUser, setConnectedUser] = useState(null)
// Mutations
const [loginMutation] = useMutation(LOGIN_MUTATION)
// Queries
const { data: dataUser } = useQuery(GET_USER_DATA_QUERY, {
variables: {
at: cookies.at || ''
}
})
// Effects
useEffect(() => {
if (dataUser) {
if (!dataUser.getUserData.id && page !== 'login') {
// If the user session is invalid and is on a different page than
// login
// we redirect them to login
redirectTo('/login?redirectTo=/dashboard')
} else {
// If we have the user data available we save it in our
// connectedUser state
setConnectedUser(dataUser.getUserData)
}
}
}, [dataUser, page])
async function login(input: { email: string; password: string }):
Promise<any> {
try {
// Executing our loginMutation passing the email and password
const { data: dataLogin } = await loginMutation({
variables: {
email: input.email,
password: input.password
}
})
if (dataLogin) {
// If the login was success, we save the token in our "at" cookie
setCookie('at', dataLogin.login.token, { path: '/' })
return dataLogin.login.token
}
} catch (err) {
// If there is an error we return it
return getGraphQlError(err)
}
}
// Exporting our context
const context = {
login,
connectedUser
}
return <UserContext.Provider value={context}>{children}</UserContext.Provider>
}
export default UserProvider
如您所见,我们正在处理登录并在上下文中获取connectedUser
数据。在这里,我们一直在执行GET_USER_DATA_QUERY
来验证用户是否已连接(根据数据库而不仅仅是 cookies 进行验证)。
配置 Apollo 客户端
到目前为止,我们已经创建了很多代码,但是如果我们不配置 Apollo 客户端,这些代码都不会起作用。要配置它,我们需要将它添加到我们位于/frontend/src/index.tsx
的索引文件中:
// Dependencies
import { render } from 'react-dom'
// Apollo
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
// Components
import AppRoutes from './AppRoutes'
// Config
import config from './config'
// Apollo Client configuration
const client = new ApolloClient({
uri: config.apiUrl,
cache: new InMemoryCache()
});
render(
<ApolloProvider client={client}>
<AppRoutes />
</ApolloProvider>
, document.querySelector('#root'))
基本上,我们要经过config.apiUrl
,这是 GraphQL 游乐场运行的地方(http://localhost:5000/graphql
,然后用ApolloProvider
组件包装我们的AppRoutes
组件。
创建我们的应用程序路线
我们将使用react-router-dom
创建我们的应用程序路由。让我们在/frontend/src/AppRoutes.tsx
处创建所需的代码:
// Dependencies
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
// Components
import HomePage from './pages/home'
import DashboardPage from './pages/dashboard'
import LoginPage from './pages/login'
import Error404 from './pages/error404'
const AppRoutes = () => (
<Router>
<Switch>
<Route path="/" component={HomePage} exact />
<Route path="/dashboard" component={DashboardPage} exact />
<Route path="/login" component={LoginPage} exact />
<Route component={Error404} />
</Switch>
</Router>
)pag
export default AppRoutes
如您所见,我们正在向路由中添加一些页面,例如HomePage
、DashboardPage
(受保护)和LoginPage
。如果用户试图访问不同的 URL,那么我们将显示一个Error404
组件。我们将在下一节中创建这些页面。
创建我们的页面
主页应位于/frontend/src/pages/home.tsx
:
const Page = () => (
<div className="home">
<h1>Home</h1>
<ul>
<li><a href="/dashboard">Go to Dashboard</a></li>
</ul>
</div>
)
export default Page
仪表板页面应位于/frontend/src/pages/dashboard.tsx
:
// Components
import DashboardLayout from '../components/dashboard/DashboardLayout'
// Contexts
import UserProvider from '../contexts/user'
const Page = () => (
<UserProvider>
<DashboardLayout />
</UserProvider>
)
export default Page
登录页面应位于/frontend/src/pages/login.tsx
:
// Dependencies
import { FC, ReactElement } from 'react'
import { isBrowser } from '@contentpi/lib'
// Contexts
import UserProvider from '../contexts/user'
// Components
import LoginLayout from '../components/users/LoginLayout'
interface IProps {
currentUrl: string
}
const Page: FC<IProps> = ({
currentUrl = isBrowser() ? window.location.search.replace
('?redirectTo=', '') :''}): ReactElement => (
<UserProvider page="login">
<LoginLayout currentUrl={currentUrl} />
</UserProvider>
)
export default Page
最后,我们需要创建我们的Error404
页面(/frontend/src/pages/error404.tsx
:
const Page = () => (
<div className="error404">
<h1>Error404</h1>
</div>
)
export default Page
我们差不多完成了。这个拼图的最后一块是创建Login
和Dashboard
组件。我们将在下一节中这样做。
创建我们的登录组件
我为我们的登录和仪表板创建了一些基本组件。当然,它们的样式可以改进,但是让我们看看它们是如何工作的,以及我们的登录系统将是什么样子。
您需要创建的第一个文件名为/frontend/src/components/users/LoginLayout.tsx
处的LoginLayout.tsx
:
// Dependencies
import { redirectTo } from '@contentpi/lib'
import { FC, ReactElement, useContext, useEffect } from 'react'
// Contexts
import { UserContext } from '../../contexts/user'
// Components
import Login from './Login'
// Interfaces
interface IProps {
currentUrl: string
}
const Layout: FC<IProps> = ({ currentUrl }): ReactElement => {
const { login } = useContext(UserContext)
return (
<Login login={login} currentUrl={currentUrl} />
)
}
export default Layout
当我们想要向组件添加特定布局时,布局文件是很好的。它还适用于使用上下文中的数据,并将数据或函数作为道具传递。
我们的Login
组件应该是这样的(/frontend/src/components/users/Login.tsx
:
// Dependencies
import { FC, ReactElement, useState, ChangeEvent } from 'react'
import { redirectTo } from '@contentpi/lib'
// Interfaces
import { IUser } from '../../types'
// Styles
import { StyledLogin } from './Login.styled'
interface IProps {
login(input: any): any
currentUrl: string
}
const Login: FC<IProps> = ({ login, currentUrl }) => {
// States
const [values, setValues] = useState({
email: '',
password: ''
})
const [errorMessage, setErrorMessage] = useState('')
const [invalidLogin, setInvalidLogin] = useState(false)
// Methods
const onChange = (e: ChangeEvent<HTMLInputElement>): void => {
const {
target: { name, value }
} = e
if (name) {
setValues((prevValues: any) => ({
...prevValues,
[name]: value
}))
}
}
const handleSubmit = async (user: IUser): Promise<void> => {
// Here we execute the login mutation
const response = await login(user)
if (response.error) {
// If the login is invalid...
setInvalidLogin(true)
setErrorMessage(response.message)
} else {
// If the login is correct...
redirectTo(currentUrl || '/')
}
}
return (
<>
<StyledLogin>
<div className="wrapper">
{invalidLogin && <div className="alert">{errorMessage}</div>}
<div className="form">
<p>
<input
autoComplete="off"
type="email"
className="email"
name="email"
placeholder="Email"
onChange={onChange}
value={values.email}
/>
</p>
<p>
<input
autoComplete="off"
type="password"
className="password"
name="password"
placeholder="Password"
onChange={onChange}
value={values.password}
/>
</p>
<div className="actions">
<button name="login" onClick={(): Promise<void> =>
handleSubmit(values)}>
Login
</button>
</div>
</div>
</div>
</StyledLogin>
</>
)
}
export default Login
我们将在下一节中创建Dashboard
组件。
创建仪表板组件
现在,让我们创建Dashboard
组件。第一个应该是位于/frontend/src/components/dashboard/DashboardLayout.tsx
的DashboardLayout.tsx
文件:
// Dependencies
import { FC, ReactElement, useContext } from 'react'
// Contexts
import { UserContext } from '../../contexts/user'
// Components
import Dashboard from './Dashboard'
const Layout: FC = () => {
const { connectedUser } = useContext(UserContext)
// We only render the Dashboard if the user is connected
if (connectedUser) {
return (
<Dashboard connectedUser={connectedUser} />
)
}
return <div />
}
export default Layout
这就是我们如何保护仪表板页面,只允许连接的用户。现在,让我们在/frontend/src/components/dashboard/Dashboard.tsx
创建Dashboard
组件:
interface IProps {
connectedUser: any
}
const Dashboard = ({ connectedUser }) => (
<div className="dashboard">
<h1>Welcome, {connectedUser.username}!</h1>
<ul>
<li><a href="/logout">Logout</a></li>
</ul>
</div>
)
export default Dashboard
这样,我们就完了!我们将在下一节中测试登录系统。
测试我们的登录系统
如果您正确地遵循了前面的部分,那么您应该能够成功地运行登录系统。为此,我们需要打开三个端子:
- 在第一种情况下,您需要运行后端项目(
npm run dev
。 - 在前端项目的第二个项目中,您需要构建您的项目(
npm run build
。 - 在最后一个例子中,您需要在前端项目(
npm run dev
中运行节点服务器。
当您第一次打开http://localhost:3000
时,您应该能够看到主页:
然后,如果您点击 Go to Dashboard(http://localhost:3000/dashboard
链接,您将被重定向到http://localhost:3000/login?redirectTo=/dashboard
,如下图所示:
这是我们的登录表。如果您试图使用一些伪造的凭据登录,则会出现错误:
如果您想查看 GraphQL 请求,可以在 Chrome 网络选项卡上查看:
在这里,您可以看到正在执行的查询和正在发送的变量(电子邮件和密码)。您可以在“预览”选项卡上看到响应:
如您所见,我们收到一条"Invalid Login"
错误消息,这就是为什么我们在Login
组件中呈现它。现在,让我们尝试连接到正确的帐户(admin@js.education / 123456
。
如果您的登录正确,则应将您重定向到仪表板,您将看到以下页面:
此外,您还可以查看正在执行的查询以检索用户数据(getUserData
):
在这里,您将看到正在返回有效负载:
我们正在从访问令牌(at
中获取用户信息。现在,如果刷新页面,则应保持与页面的连接。这是因为我们保存了一个包含令牌的 cookie:
现在,让我们尝试通过更改令牌的任何字母来修改 cookie。例如,我们将前两个字母(ey
改为XX
:
在这里,您将收到用户的空数据。这将使会话无效,并再次将您重定向到登录页面:
至此,您已经了解了如何在后端实现 GraphQL,以及如何在前端使用查询和突变。
这个登录系统是我在 YouTube 上做的一门课程的一部分,我在那里教观众如何从头开始开发无头 CMS,所以如果你想了解更多,你可以在上查看课程 https://www.youtube.com/watch?v=4n1AfD6aV4M 。
总结
我真的希望您喜欢阅读本章,其中包含了大量关于 GraphQL 的信息,以及如何创建 JWT、执行登录和使用 Sequelize 创建模型。
现在是讨论数据获取和单向数据流的时候了,这是我们将在下一章中讨论的内容。**
版权属于:月萌API www.moonapi.com,转载请注明出处