使用 fastify 和 TypeORM 构建 REST API
一家餐厅希望能够数字化管理库存,以便更轻松地追踪产品过期时间,并采用更加数据驱动的方式开展工作。我有机会用 React Native 和 Typescript 构建了一个原型。
这就是我使用 fastify 和 TypeORM 创建后端 api 的方法。
您可以在 Github 上找到示例项目:https://github.com/carlbarrdahl/fastify-server-example
要求
- 库存应存储在 MSSQL 数据库中
- REST api 与数据库通信
- 只有授权用户才能访问 API
我们将介绍
- 使用 fastify 构建 REST API
- 集成测试
- 使用 TypeORM 进行数据库连接
- 用于客户端数据验证和定义允许的响应的 JSON Schema
- 使用 JWT 保护端点
- Swagger 中自动生成的文档
fastify 中的 REST API
我决定使用 fastify 作为服务器框架来编写 API,因为它速度快、模块化,而且易于使用和测试。它的插件系统也拥有强大的生态系统,您可以轻松编写自己的插件,我们稍后会看到这一点。
确保 API 正常运行的一个好方法是编写集成测试。通过针对测试套件进行开发,我们可以获得快速的反馈循环,而无需手动调用 API 来检查其是否按预期运行。
我首先明确了预期的行为:
test("GET /products returns list of products", () => {})
test("DELETE /products/:id deletes a product", () => {})
test("GET /inventory returns list of inventory", () => {})
test("POST /inventory/:id creates a product", () => {})
test("DELETE /inventory/:id deletes an inventory", () => {})
test("JWT token is required for endpoints", () => {})
为了在 fastify 中测试端点,我们可以使用它来inject
模拟对服务器的请求并传递方法、url、标头和有效负载,然后确保响应符合我们的预期。
// test/server.test.ts
import createServer from "../src/server"
const server = createServer()
test("GET /inventory returns list of inventory", async done => {
server.inject({ method: "GET", url: `/inventory` }, (err, res) => {
expect(res.statusCode).toBe(200)
expect(JSON.parse(res.payload)).toEqual([]) // expect it to be empty for now
done()
})
})
通过使用 fastify 的插件系统,我们可以将应用程序模块化,以便根据需要更轻松地拆分成更小的部分。我选择采用以下文件夹结构:
/src
/modules
/health
/routes.ts
/schema.ts
/product
/entity.ts
/routes.ts
/schema.ts
/inventory
/entity.ts
/routes.ts
/schema.ts
/plugins
/auth.ts
/jwt.ts
/printer.ts
/server.ts
/index.ts
/test
/server.test.ts
库存路线可能如下所示:
// src/modules/inventory/routes.ts
module.exports = (server, options, next) => {
server.get(
"/inventory",
// we will cover schema and authentication later
{ preValidation: [server.authenticate], schema: listInventorySchema },
async (req, res) => {
req.log.info(`list inventory from db`)
const inventory = [] // return empty array for now to make the test green
res.send(inventory)
}
)
// routes and controllers for create, delete etc.
next()
}
我们的测试现在应该是绿色的,这是一个好兆头!
但是,总是返回空数组的库存 API 不太好用。让我们连接一个数据源!
使用 TypeORM 连接数据库
你可能会问,什么是 ORM?大多数数据库都有不同的通信方式。ORM 将这些通信方式规范化为统一的方式,这样我们就可以轻松地在不同类型的受支持数据库之间切换,而无需更改实现。
首先让我们创建实体(或模型):
// src/modules/inventory/entity.ts
@Entity()
export class Inventory {
@PrimaryGeneratedColumn("uuid")
id: string
// Each product can exist in multiple inventory
@ManyToOne(type => Product, { cascade: true })
@JoinColumn()
product: Product
@Column()
quantity: number
@Column("date")
expiry_date: string
@CreateDateColumn()
created_at: string
@UpdateDateColumn()
updated_at: string
}
接下来,我们将使用该插件连接到数据库,并使用我们的数据存储库创建一个装饰器。这样,我们就可以轻松地从路由访问它们。
// src/plugins/db.ts
import "reflect-metadata"
import fp from "fastify-plugin"
import { createConnection, getConnectionOptions } from "typeorm"
import { Inventory } from "../modules/inventory/entity"
module.exports = fp(async server => {
try {
// getConnectionOptions will read from ormconfig.js (or .env if that is prefered)
const connectionOptions = await getConnectionOptions()
Object.assign(connectionOptions, {
options: { encrypt: true },
synchronize: true,
entities: [Inventory, Product]
})
const connection = await createConnection(connectionOptions)
// this object will be accessible from any fastify server instance
server.decorate("db", {
inventory: connection.getRepository(Inventory),
products: connection.getRepository(Product)
})
} catch (error) {
console.log(error)
}
})
// ormconfig.js
module.exports = {
type: "mssql",
port: 1433,
host: "<project-name>.database.windows.net",
username: "<username>",
password: "<password>",
database: "<db-name>",
logging: false
}
我们现在可以添加插件createServer
并更新查询数据库的路线:
// src/server.ts
server.use(require("./plugins/db"))
// src/modules/inventory/routes.ts
const inventory = await server.db.inventory.find({
relations: ["product"] // populate the product data in the response
})
除非我们希望测试能够查询生产数据库,否则我们必须设置一个内存测试数据库,或者直接模拟它。让我们在测试中创建一个模拟数据库:
// test/server.test.ts
import typeorm = require('typeorm')
const mockProducts = [{...}]
const mockInventory = [{...}]
const dbMock = {
Product: {
find: jest.fn().mockReturnValue(mockProducts),
findOne: jest.fn().mockReturnValue(mockProducts[1]),
remove: jest.fn()
},
Inventory: {
find: jest.fn().mockReturnValue(mockInventory),
findOne: jest.fn().mockReturnValue(mockInventory[1]),
save: jest.fn().mockReturnValue(mockInventory[0]),
remove: jest.fn()
}
}
typeorm.createConnection = jest.fn().mockReturnValue({
getRepository: model => dbMock[model.name]
})
typeorm.getConnectionOptions = jest.fn().mockReturnValue({})
测试如何查找创建库存路线:
test("POST /inventory/:id creates an inventory", done => {
const body = { product_id: mockProducts[0].id, quantity: 1 }
server.inject(
{
method: "POST",
url: `/inventory`,
payload: body,
headers: {
Authorization: `Bearer ${token}`
}
},
(err, res) => {
expect(res.statusCode).toBe(201)
// assert that the database methods have been called
expect(dbMock.Product.findOne).toHaveBeenCalledWith(body.product_id)
expect(dbMock.Inventory.save).toHaveBeenCalled()
// assert we get the inventory back
expect(JSON.parse(res.payload)).toEqual(mockInventory[0])
done(err)
}
)
})
我们如何知道在创建库存时发送了正确的数据?
使用 JSON 模式验证请求
fastify 的另一个优点是它带有使用 json-schema 规范的内置模式验证。
为什么这很重要?
我们永远无法知道客户端发送了什么数据,也不想手动检查每个路由的请求体。相反,我们希望描述这些请求可能是什么样子,以及预期会得到什么样的响应。如果客户端发送的数据与 schema 不匹配,fastify 会自动抛出错误。这样可以写出简洁易懂的代码,避免不必要的 if 语句。
注意:这并不能防范潜在的 XSS 攻击。恶意攻击者仍然能够发送恶意 JavaScript 代码。我们可以使用 fastify-helmet 的 xssFilter 来防御此类攻击。
除了验证之外,我们还可以根据这些规范自动为我们的路由生成 Swagger 文档,以便开发人员了解如何使用 API。太棒了!
这些 JSON 格式的 schema 被定义为简单对象。以下是库存路由的 schema:
const inventorySchema = {
id: { type: "string", format: "uuid" },
product_id: { type: "string", format: "uuid" },
// note the reference to the productSchema ↘
product: { type: "object", properties: productSchema },
quantity: { type: "number", min: 1 },
expiry_date: { type: "string", format: "date-time" },
created_at: { type: "string", format: "date-time" },
updated_at: { type: "string", format: "date-time" }
}
export const listInventorySchema = {
summary: "list inventory",
response: {
200: {
type: "array",
items: {
properties: inventorySchema
}
}
}
}
export const postInventorySchema = {
summary: "create inventory",
body: {
// incoming request body
type: "object",
required: ["product_id", "quantity"],
properties: {
product_id: { type: "string", format: "uuid" },
quantity: { type: "integer", minimum: 1 }
}
},
response: {
201: {
type: "object",
properties: inventorySchema
}
}
}
Fastify 现在会对收到的数据非常挑剔,如果缺少某些内容或类型不正确,它会告诉我们。
尽管如此,任何人都可以访问我们的 API。接下来,我们将研究如何使用 JSON Web Token 将其限制为仅拥有有效密钥的客户端。
授权
为了保护我们的 API,我们将使用 json web 令牌。https
://jwt.io/introduction/
JWT.io 是这么说的:
授权:这是使用 JWT 最常见的场景。用户登录后,每个后续请求都将包含 JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是如今 JWT 广泛使用的一项功能,因为它开销小,并且易于跨域使用。
信息交换: JSON Web Token 是各方之间安全传输信息的有效方式。由于 JWT 可以签名(例如,使用公钥/私钥对),因此您可以确保发送者的身份与其声明相符。此外,由于签名是使用标头和有效负载计算得出的,因此您还可以验证内容未被篡改。
这意味着我们既可以用它来验证用户的身份,又可以安全地共享机密数据。在本例中,我们将用它来简单地授权一个共享用户。
我们将使用 fastify 插件来导入库并authenticate
用验证我们的令牌的请求处理程序进行装饰。
// src/plugins/auth.ts
import fp from "fastify-plugin"
export default fp((server, opts, next) => {
server.register(require("fastify-jwt"), {
secret: "change this to something secret"
})
server.decorate("authenticate", async (req, res) => {
try {
await req.jwtVerify()
} catch (err) {
res.send(err)
}
})
next()
})
然后,我们在每个请求中运行authenticate
钩子preValidation
以确保 jwt 有效。
在内部,它检索token
传入的授权标头并验证它是否已使用我们的密钥进行签名。
// src/modules/inventory/routes.ts
server.post(
"/inventory",
// authenticate the request before we do anything else
{ preValidation: [server.authenticate], schema: postInventorySchema },
async (req, res) => {
const { quantity, product_id } = req.body
req.log.info(`find product ${product_id} from db`)
const product = await server.db.products.findOne(product_id)
if (!product) {
req.log.info(`product not found: ${product_id}`)
return res.code(404).send("product not found")
}
req.log.info(`save inventory to db`)
const inventory = await server.db.inventory.save({
quantity,
product,
expiry_date: addDays(product.expires_in)
})
res.code(201).send(inventory)
}
)
由于我们现在没有实现任何用户帐户,因此我们可以生成如下临时令牌:
server.ready(() => {
const token = server.jwt.sign({ user_id: "<user_id>" })
console.log(token)
})
你可能已经注意到,令牌是一个经过 Base64 编码的签名对象(包含一些其他内容)。我们可以用它来限制特定用户或用户创建的清单的访问权限。例如:
// src/modules/inventory/routes.ts
server.get(
"/inventory/:id",
{ schema: getInventorySchema, preValidation: [server.authenticate] },
async (req, res) => {
const inventory = await server.db.inventory.findOne(req.params.id)
// Make sure the requesting user is the same as the inventory owner
if (req.user.user_id !== inventory.owner.id) {
throw new Error("Unauthorized access")
}
res.send(inventory)
}
)
更高级的用法可以检查令牌发出时的时间戳(iat
)。
Swagger 文档
我一直在说的 Swagger 文档是什么?它基本上为你的 API 提供了一个可视化的界面,让你可以查看它的工作原理、请求体应该是什么样子以及示例响应。基本上我们在 JSON Schema 中定义的内容都以文档的形式公开了。
这是使用的配置createServer
:
server.register(require("fastify-oas"), {
routePrefix: "/docs",
exposeRoute: true,
swagger: {
info: {
title: "inventory api",
description: "api documentation",
version: "0.1.0"
},
servers: [
{ url: "http://localhost:3000", description: "development" },
{ url: "https://<production-url>", description: "production" }
],
schemes: ["http"],
consumes: ["application/json"],
produces: ["application/json"],
security: [{ bearerAuth: [] }],
securityDefinitions: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
}
}
})
未来的改进
- 用户帐户
- 缓存
- 改进的错误处理
- 使用 fastify-helmet 增强了对 XSS 及其他方面的安全性
- 负载均衡
您觉得这篇文章怎么样?
你学到了新东西吗?有什么难以理解的地方吗?代码太多了?还是代码不够?我的做法是不是完全错了?请在评论区留言告诉我。
鏂囩珷鏉ユ簮锛�https://dev.to/carlbarrdahl/building-a-rest-api-using-fastify-and-typeorm-39bp