JSONAPI

在 Swift 中轻松进行 JSON:API 编码和解码。

动机

使用 Swift 编码和解码 JSON:API 响应可能会带来一些挑战。以下是一些常见问题:

快速开始

JSON:API 规范中一个反复出现的例子是包含文章列表的响应。每篇文章都有一位作者和零个或多个评论。每条评论可以选择性地有一位作者

我们可以将这些资源建模为简单的结构体,并使用 @ResourceWrapper 宏来启用 JSON:API 编码和解码。

要定义资源的属性和关系,我们分别使用用 @ResourceAttribute@ResourceRelationship 注释的属性。

@ResourceWrapper(type: "people")
struct Person: Equatable {
  var id: String

  @ResourceAttribute var firstName: String
  @ResourceAttribute var lastName: String
  @ResourceAttribute var twitter: String?
}

@ResourceWrapper(type: "comments")
struct Comment: Equatable {
  var id: String

  @ResourceAttribute var body: String
  @ResourceRelationship var author: Person?
}

@ResourceWrapper(type: "articles")
struct Article: Equatable {
  var id: String

  @ResourceAttribute var title: String
  @ResourceRelationship var author: Person
  @ResourceRelationship var comments: [Comment]
}

要从 JSON:API 响应中解码 Article 值的数组,我们必须使用 JSONAPIDecoder 对象。JSONAPIDecoder 是一个 JSONDecoder 子类,它允许解码并将响应中任何 included 的相关资源嵌入其中。

let decoder = JSONAPIDecoder()
let articles = try decoder.decode([Article].self, from: json)

同样,我们可以使用 JSONAPIEncoder 对象将文章数组编码回 JSON:API 响应。与它的解码对应物一样,JSONAPIEncoder 允许将相关资源编码到 included 数组中。

let encoder = JSONAPIEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]

let data = try encoder.encode(articles)

重要提示

我们建议不要修改用 @ResourceWrapper 注释的模型中的关系,因为维护跨重复实例的一致性可能具有挑战性。

创建和更新资源

当向 JSON:API 后端发送创建或更新请求时,您不需要提供所有属性或关系。 同样,在更新关系时,您只需要提供相关资源标识符,而无需提供整个相关资源。

@ResourceWrapper 宏生成便利方法来构建创建或更新请求的主体。

以下示例显示了如何获取创建新文章的请求的主体

let newArticle = Article.createBody(
  title: "A guide to parsing JSON:API with Swift",
  author: "9"
)

// You can use a regular `JSONEncoder` to encode the request body
let encoder = JSONEncoder()
let data = try encoder.encode(newArticle)

请注意,您只需要提供作者关系的标识符。为了方便起见和类型安全,关系参数类型编码了相关资源类型字符串(在本例中为 "people")。

以下是另一个示例,显示了如何获取将一些评论添加到现有文章的请求的主体。

let articleUpdate = Article.updateBody(id: "1", comments: ["5", "12"])

多态关系

多态关系是一种资源可以与多种类型的资源相关的关系。这意味着单个关系字段可以引用不同的资源类型。

例如,Article 可能具有可以指向 PersonOrganization 资源的 "contributors" 关系。

"relationships": {
  "contributors": {
    "data": [
      {
        "type": "people",
        "id": "12"
      },
      {
        "type": "organizations",
        "id": "25"
      }
    ]
  }
}

我们可以像处理其他资源一样,利用 @ResourceWrapper 宏来建模 Organization 资源。

@ResourceWrapper(type: "organizations")
struct Organization: Equatable {
  var id: String

  @ResourceAttribute var name: String
  @ResourceAttribute var contactEmail: String
}

对于 "contributors" 关系,我们必须创建一个组合 PersonOrganization 类型的类型。我们可以使用具有关联值的 enum 类型来实现这一点,然后使用 @ResourceUnion 宏对其进行注释。

@ResourceUnion
enum Contributor: Equatable {
  case person(Person)
  case organization(Organization)
}

完成之后,我们可以将 contributors 关系添加到 Article

@ResourceWrapper(type: "articles")
struct Article: Equatable {
  var id: String

  @ResourceAttribute var title: String

  @ResourceRelationship var author: Person
  @ResourceRelationship var comments: [Comment]
  
  // Both people and organizations can contribute to an article
  @ResourceRelationship var contributors: [Contributor]
}

除其他事项外,@ResourceUnion 宏会生成一个 ID 类型,您可以使用该类型来标识参与 union 的资源。例如,以下是如何构建请求以更新现有文章的贡献者的主体

let articleUpdate = Article.updateBody(
  id: "1",
  contributors: [
    .person("12"),
    .organization("24"),
    .person("66")
  ]
)

顶层元信息

一些 JSON:API 响应可能包含顶层元信息,以提供不适合主要数据的其他详细信息,例如请求标识符或分页元数据。

{
  "meta": {
    "requestId": "abcd-1234",
    "pagination": {
      "totalPages": 10,
      "currentPage": 2
    },
  },
  "data": [
    {
      "type": "articles",
      "id": "1",
      ...
    },
    ...
  ]
}

要从 JSON:API 响应中获取顶层元信息,您必须提供合适的 Codable 模型,并将其与 CompoundDocument 类型一起使用。

struct Meta: Equatable, Codable {
  struct Pagination: Equatable, Codable {
    let totalPages: Int
    let currentPage: Int
  }
  
  let requestId: String
  let pagination: Pagination
}

typealias ArticlesDocument = CompoundDocument<[Article], Meta>

let decoder = JSONAPIDecoder()
let document = try decoder.decode(ArticlesDocument.self, from: json)

let currentPage = document.meta.pagination.currentPage
let articles = document.data

错误处理

在解码 JSON API 响应时,有些情况下您需要灵活性,并且希望获得不完整的响应而不是错误。

缺少包含的资源

如果解码器无法在 included 部分中找到关系引用的资源,则会抛出 DecodingError.valueNotFound 错误。

我们可以通过使用可选类型来防止在“一对一”关系中出现这种情况。

@ResourceRelationship var author: Person?

对于“一对多”关系,我们需要指示解码器忽略缺少的资源。

let decoder = JSONAPIDecoder()
decoder.ignoresMissingResources = true

let article = try decoder.decode(Article.self, from: json)
// Ignores any missing resources in the `comments` relationship

多态关系中未处理的资源类型

在解码多态关系时,如果解码器找到一个资源类型未包含在资源联合中,它会抛出一个 JSONAPIDecodingError.unhandledResourceType 错误。例如,考虑后端添加了一种客户端不知道的新型 Article 贡献者的情况。

"relationships": {
  "contributors": {
    "data": [
      {
        "type": "people",
        "id": "12"
      },
      {
        "type": "organizations",
        "id": "25"
      },
      {
        "type": "teams",
        "id": "13"
      },
    ]
  }
}

我们必须指示解码器忽略未处理的资源类型以防止此错误。

let decoder = JSONAPIDecoder()
decoder.ignoresUnhandledResourceTypes = true

let article = try decoder.decode(Article.self, from: json)
// Ignores new types of contributors in the `contributors` relationship

此外,对于“一对一”关系,我们必须使用可选类型。

@ResourceRelationship var reviewer: Contributor?

状态和路线图

JSONAPI Swift 库已准备好投入生产,我们正在 Datadog iOS 应用程序中积极使用它。但是,在我们能够涵盖 JSON:API 规范的更多部分并进一步评估社区采用和反馈之前,我们将推迟发布版本 1.0

安装

将 JSONAPI 添加到 Swift 包

要在 Swift Package Manager 项目中使用 JSONAPI,请将以下行添加到 Package.swift 文件中的依赖项中

.package(url: "https://github.com/Datadog/swift-jsonapi", from: "0.1.0")

"JSONAPI" 作为可执行目标的依赖项包含在内

.target(name: "<target>", dependencies: [
  .product(name: "JSONAPI", package: "swift-jsonapi")
]),

最后,将 import JSONAPI 添加到您的源代码中。

将 JSONAPI 添加到 Xcode 项目

  1. File 菜单中,选择 Add Packages…
  2. Search or Enter Package URL搜索字段中输入https://github.com/Datadog/swift-jsonapi
  3. JSONAPI 链接到您的应用程序目标