使用 fastify 和 TypeORM 构建 REST API

2025-06-11

使用 fastify 和 TypeORM 构建 REST API

一家餐厅希望能够数字化管理库存,以便更轻松地追踪产品过期时间,并采用更加数据驱动的方式开展工作。我有机会用 React Native 和 Typescript 构建了一个原型。

这就是我使用 fastify 和 TypeORM 创建后端 api 的方法。

您可以在 Github 上找到示例项目:https://github.com/carlbarrdahl/fastify-server-example

要求

  • 库存应存储在 MSSQL 数据库中
  • REST api 与数据库通信
  • 只有授权用户才能访问 API

我们将介绍

  1. 使用 fastify 构建 REST API
  2. 集成测试
  3. 使用 TypeORM 进行数据库连接
  4. 用于客户端数据验证和定义允许的响应的 JSON Schema
  5. 使用 JWT 保护端点
  6. 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", () => {})


Enter fullscreen mode Exit fullscreen mode

为了在 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()
  })
})


Enter fullscreen mode Exit fullscreen mode

通过使用 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


Enter fullscreen mode Exit fullscreen mode

库存路线可能如下所示:



// 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()
}


Enter fullscreen mode Exit fullscreen mode

我们的测试现在应该是绿色的,这是一个好兆头!

但是,总是返回空数组的库存 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
}


Enter fullscreen mode Exit fullscreen mode

接下来,我们将使用该插件连接到数据库,并使用我们的数据存储库创建一个装饰器。这样,我们就可以轻松地从路由访问它们。



// 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)
  }
})


Enter fullscreen mode Exit fullscreen mode


// ormconfig.js
module.exports = {
  type: "mssql",
  port: 1433,
  host: "<project-name>.database.windows.net",
  username: "<username>",
  password: "<password>",
  database: "<db-name>",
  logging: false
}


Enter fullscreen mode Exit fullscreen mode

我们现在可以添加插件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
})


Enter fullscreen mode Exit fullscreen mode

除非我们希望测试能够查询生产数据库,否则我们必须设置一个内存测试数据库,或者直接模拟它。让我们在测试中创建一个模拟数据库:



// 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({})


Enter fullscreen mode Exit fullscreen mode

测试如何查找创建库存路线:



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)
    }
  )
})


Enter fullscreen mode Exit fullscreen mode

我们如何知道在创建库存时发送了正确的数据?

使用 JSON 模式验证请求

fastify 的另一个优点是它带有使用 json-schema 规范的内置模式验证。

为什么这很重要?

我们永远无法知道客户端发送了什么数据,也不想手动检查每个路由的请求体。相反,我们希望描述这些请求可能是什么样子,以及预期会得到什么样的响应。如果客户端发送的数据与 schema 不匹配,fastify 会自动抛出错误。这样可以写出简洁易懂的代码,避免不必要的 if 语句。

注意:这并不能防范潜在的 XSS 攻击。恶意攻击者仍然能够发送恶意 JavaScript 代码。我们可以使用 fastify-helmet 的 xssF​​ilter 来防御此类攻击。

除了验证之外,我们还可以根据这些规范自动为我们的路由生成 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
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

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()
})


Enter fullscreen mode Exit fullscreen mode

然后,我们在每个请求中运行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)
  }
)


Enter fullscreen mode Exit fullscreen mode

由于我们现在没有实现任何用户帐户,因此我们可以生成如下临时令牌:



server.ready(() => {
  const token = server.jwt.sign({ user_id: "<user_id>" })
  console.log(token)
})


Enter fullscreen mode Exit fullscreen mode

你可能已经注意到,令牌是一个经过 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)
  }
)


Enter fullscreen mode Exit fullscreen mode

更高级的用法可以检查令牌发出时的时间戳(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"
}
}
}
})

Enter fullscreen mode Exit fullscreen mode




未来的改进

  • 用户帐户
  • 缓存
  • 改进的错误处理
  • 使用 fastify-helmet 增强了对 XSS 及其他方面的安全性
  • 负载均衡

您觉得这篇文章怎么样?

你学到了新东西吗?有什么难以理解的地方吗?代码太多了?还是代码不够?我的做法是不是完全错了?请在评论区留言告诉我。

鏂囩珷鏉ユ簮锛�https://dev.to/carlbarrdahl/building-a-rest-api-using-fastify-and-typeorm-39bp
PREV
将 DEV.TO 引入您的博客
NEXT
使用 Python 的人脸检测技术来解决这个问题