使用 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 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com
          