Swift on Server Tour 3 构建 Post 的 API

在本章中,我们将为 Post 配置 API,使得我们可以通过 HTTP 请求来完成 Post 的创建。同时,我们将引入 Environment 来为我们的服务器区分 Development 和 Testing 环境。

Table of Contents

API 的基础理念

在我们编程的时候,经常会使用第三方提供的接口来完成一个特定任务,使得原本复杂的任务,只需要调用一个 API 就可以完成,这便是 API 最基础的理念,将特定的任务进行封装。

API 几乎无处不在,以用户使用 App 发微博为例,可以表示为下面的流程

User -> UI -> Client -> HTTP -> Server -> Fluent -> Database

用户操作 UI 编写内容,内容被记录在客户端 App,客户端通过 HTTP 请求发送到服务器,服务器接收到请求后,这些请求通过 Fluent 转化成 SQL 语句,最终在数据库中完成数据更新。

  • UI 提供了「用户」和「客户端」之间的 API

  • HTTP 接口提供了「客户端」和「服务器」之间的 API

  • Fluent 提供了「服务器」和「数据库」之间的 API

在本章中,我们将要设计的就是 Client -> Server 之间的 HTTP API.

创建 Post 的 HTTP API

在第一章中,我们曾提供了一个非常基础的 "It works!" 的 API,了解了一个 HTTP 请求所需的基本参数。

现在,我们希望有一个可以创建 Post 的 API,需求如下

  • 使用 POST 请求
  • 路径为 /posts
  • 请求内容为 JSON,内容包含一个 content 字段来表示 Post 的内容

如果客户端按照这个标准发来一个 HTTP 请求,那么内容看起来就是这样的

POST /posts HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Content-Length: 49

{
    "content": "First post from HTTP request"
}

Hint

需要客户端填写的是 POST /posts 和 JSON 部分的内容,其他如Host, Content-Type, Content-Length 均会在请求时自动生成。

首先,定义一个叫做 Post.CreateDTO 的 DTO struct 来表示客户端发来的 JSON 内容格式

extension Post {
struct CreateDTO {
let content: String
}
}

Hint

DTO,全称 Data Transfer Object,是一种常见的设计模式,用于在服务之间传输数据,可以灵活定义字段,便于封装和安全性验证,亦可减少流量的消耗。

接下来编辑 Sources/App/configure.swift 加入对 POST /posts 的处理

import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) throws {
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: "localhost",
port: 5432,
username: "vapor_username",
password: "vapor_password",
database: "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
app.migrations.add([CreatePost()])
app.post("posts") { req async throws -> Post in
let postData = try req.content.decode(Post.CreateDTO.self)
let post = Post(content: postData.content)
try await post.create(on: req.db)
return post
}
}
  • req.content.decode(Post.CreateDTO.self) 表示将请求中 body 的内容解码为 Post.CreateDTO

修改 Sources/App/Models/Post.swift 使 Post 支持被编码为 JSON

import Vapor
import Fluent
final class Post: Model, Content {
// 数据库中的表名
static let schema = "posts"
// 唯一性标识符
@ID(key: .id)
var id: UUID?
// 内容
@Field(key: "content")
var content: String
// 创建时间
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
init(id: UUID? = nil, content: String) {
self.id = id
self.content = content
}
}
extension Post {
struct CreateDTO: Content {
let content: String
}
}
  • 给 Post 增加 Content 协议,使之具备 Codable 的特性,能够被编码成 JSON 返回给用户
  • 增加了一个Post.CreateDTO 的结构体,同样遵循了 Content 协议,使之可以用来解码用户发来的 JSON

测试 API

现在,我们可以修改 AppTests/PostTests.swift 来测试这个 API 是否能够跑通了

@testable import App
import XCTVapor
final class PostTests: XCTestCase {
func testCreatePost() async throws {
let app = Application(.testing)
defer { app.shutdown() }
try configure(app)
try await app.autoRevert()
try await app.autoMigrate()
let postDTO = Post.CreateDTO(content: "Post created from test")
try app.test(.POST, "posts", beforeRequest: { req in
try req.content.encode(postDTO)
}, afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let post = try res.content.decode(Post.self)
XCTAssertEqual(postDTO.content, post.content)
})
}
}
  • 使用 app.test(.POST, "posts") 模拟外部 HTTP 请求
  • beforeRequest 可以对请求数据进行修改,我们通过 req.content.encode(postDTO) 将 postDTO 编码到 request 的 body 中,在默认情况下,请求时数据会采用 JSON 编码
  • afterResponse 是请求后从服务器获得的响应,我们首先判断 res.status 是否是 HTTPStatus.ok 即 200
  • XCTAssertEqual(postDTO.content, post.content) 判断服务器上创建的数据是否和发送的一致

运行这段测试,不出意外,将可以看到测试通过的信息

Test Case '-[AppTests.PostTests testCreatePost]' passed (0.269 seconds).

恭喜你!第一个 API 调通了!

使用 cURL 命令进行测试

除了使用 Unit Test 进行 API 测试以外,我们也可以使用 cURL 命令进行测试,以上面创建 Post 的请求为例,我们可以在终端中输入以下命令

curl -i --location 'http://127.0.0.1:8080/posts' \
--header 'Content-Type: application/json' \
--data '{
"content": "First post from HTTP request"
}'

Hint

通过 -i 参数,我们要求 curl 打印出完整的 HTTP 响应信息

回车后,正常情况下会响应如下内容

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 121
connection: keep-alive
date: Sun, 23 Jul 2023 13:22:20 GMT
{"content":"First post from HTTP request","createdAt":"2023-07-23T13:22:20Z","id":"1C9A6A1D-98B8-4871-8DE0-BDF011845ADA"}%

通过 cURL 我们可以很方便的观察 HTTP 信息的构成,通过这则信息,我们也可以更好的理解 Unit Test 中 XCTAssertEqual(res.status, .ok)200 OK 的对应关系。

使用 Postman 进行测试

Postman 是一款非常流行的 API 测试软件,不仅可以方便的进行参数调试,也可以将编辑好的请求转换成其他软件的请求,比如下图右边转换为 cURL

image.png

列出所有 Post

创建 Post 之后,最迫切的期望就是列出所有的 Post,现在我们可以编辑 Sources/App/configure.swift 来加入 GET /posts 的支持

import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) throws {
...
app.get("posts") { req async throws -> [Post] in
let posts = try await Post.query(on: req.db).all()
return posts
}
}
  • Post.query(on: req.db).all() 将获取数据库中所有的 Post.

现在,如果你使用浏览器访问 http://127.0.0.1:8080/posts 将可以看到所有存储在数据库里的 posts

image.png

使用 Postman 来查看会更方便一些

image.png

使用 Environment 区分服务器环境

到目前为止,我们并没有区分服务器的开发环境和测试环境,这导致每次运行单元测试的时候,都会将我们之前在开发环境里创建的数据清除掉,这显然会给我们带来很多麻烦。

我们的数据库连接信息定义在 Sources/App/configure.swift 之中,这意味着如果我们可以让 App 启动时动态的配置数据库的连接信息,分别连接「开发环境数据库」和「测试环境数据库」,就可以解决我们的问题。

在 Vapor 中,通过 Environment 我们可以轻松的将他们分开。

回顾 Sources/App/main.swift,我们使用 let app = Application() 启动我们的 App,但实际上这段代码隐藏了一些细节,它包含了 Environment 的默认值 Environment.development

因此更详尽的代码应该是 let app = Application(.development)

import Vapor
import Fluent
import FluentPostgresDriver
let app = Application(.development)
app.http.server.configuration.port = 8080
defer { app.shutdown() }
app.get { req async in
"It works!"
}
try configure(app)
try app.run()
  • Application() 在启动时,接受 Environment 变量,并读取与 Environment 对应的 .env 文件
  • 在上面的 Application(.development) 代码中,Application 会读取 .env.developemnt

Hint

Vapor 的服务器启动时,默认是 development 环境,因此会从服务器启动目录的 .env.development 中读取环境变量,如果你使用的 Xcode,请记得修改 Scheme 中的 Working Directory 为项目根目录

修改编辑项目根目录中的.env.development 配置开发环境变量

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=vapor_username
DATABASE_PASSWORD=vapor_password
DATABASE_NAME=vapor_database

修改 Sources/App/configure.swift 改变数据库参数的获取方式

app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ?? "vapor_username",
password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password",
database: Environment.get("DATABASE_NAME") ?? "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)

现在,我们只解决了 develpment 环境的问题,testing 环境需要一个新的数据库,以及一份对应的 .env.testing

修改 docker-compose.yml 增加 db_test 服务

值得注意的是我们在 volumes 增加了 db_data_test,并将 db_test 的端口设置为了 5442

version: '3.7' # 定义 Docker Compose 文件的版本,此处使用的是版本 3.7
volumes: # 定义卷部分
db_data: # docker 会使用这个键作为名字,自动创建 db_data 卷来存储数据
db_data_test:
services: # 定义服务部分
db: # db 服务配置
image: 'postgres:15-alpine' # 使用 PostgreSQL 15 Alpine 版本的镜像
volumes: # 定义挂载卷
- 'db_data:/var/lib/postgresql/data/pgdata' # 将 db_data 卷挂载到容器的 /var/lib/postgresql/data/pgdata 目录
environment: # 定义环境变量
PGDATA: '/var/lib/postgresql/data/pgdata' # 设置 PGDATA 环境变量为 /var/lib/postgresql/data/pgdata
POSTGRES_USER: 'vapor_username' # 设置 POSTGRES_USER 环境变量为 vapor_username
POSTGRES_PASSWORD: 'vapor_password' # 设置 POSTGRES_PASSWORD 环境变量为 vapor_password
POSTGRES_DB: 'vapor_database' # 设置 POSTGRES_DB 环境变量为 vapor_database
ports: # 定义端口映射,将主机的 5432 端口映射到容器的 5432 端口
- '5432:5432'
db_test:
image: 'postgres:15-alpine'
volumes:
- 'db_data_test:/var/lib/postgresql/data/pgdata'
environment: # 定义环境变量
PGDATA: '/var/lib/postgresql/data/pgdata'
POSTGRES_USER: 'vapor_username'
POSTGRES_PASSWORD: 'vapor_password'
POSTGRES_DB: 'vapor_database'
ports:
- '5442:5432'

创建 .env.testing 写入以下内容

DATABASE_HOST=localhost
DATABASE_PORT=5442
DATABASE_USERNAME=vapor_username
DATABASE_PASSWORD=vapor_password
DATABASE_NAME=vapor_database

启动 db_test

docker-compose up db_test -d

现在,服务器的 development 和 testing 环境的数据库就不会再相互干扰了。

本章节的代码可以在 https://github.com/kevinzhow/swift-on-server-tour/tree/main/3 中找到

下章预告

目前我们 Post 相关的操作都堆在了 configure.swift 中,在下一章中,我们将学习如何将他们移动到 Controller 中,并继续完善我们 Post 的查看与删除。