显示导航

使用带有 JSON 视图的 HAL

了解如何使用 Grails 构建可发现的 API

作者:ZacharyKlein

Grails 版本 3.3.1

1 Grails 培训

Grails 培训 - 由创建并积极维护了 Grails 框架的人员设计并提供!。

2 开始使用

在本指南中,您将利用 JSON 视图(由 Grails Views 库提供),在 Grails 中探索 HAL(超文本应用程序语言)支持。

超文本应用程序语言 (HAL) 是一个 Internet 草案(一个“正在进行中工作”)标准惯例,用于在 JSON 或 XML 代码中定义超媒体,例如链接到外部资源。

— 维基百科

HAL 的目的是使 API “可发现” - 它定义了一组惯例,使用这些惯例,您的 API 使用者可以访问资源之间的链接,并且为“探索”API 提供分页和其他便利功能。HAL 是实现 HATEOAS(超媒体作为应用程序状态引擎)架构的流行标准,它是基本 REST 架构的扩展。

HAL Resource Structure
图 1. HAL 资源结构,来源:http://stateless.co
如需了解 HAL 的全面简介,请查看以下 URL 中的概述和规范:http://stateless.co/hal_specification.html

Grails 通过 JSON 视图来支持 HAL,这属于 Grails Views 库的一部分。您可以在现有应用程序中使用本库,它遵循了文档中的安装步骤,或者您也可以创建一个新应用程序,只需使用 `rest-api` 概要文件或其中一个前端概要文件(`angular`、`angular2` 和 `react`),它们都扩展了 `rest-api` 概要文件。

在此指南中,我们在 `initial` 项目中使用 `rest-api` 概要文件提供了一个基本的 Grails 3.3.1 应用程序。我们还包括了一些域类,它们可通过我们的 API 呈现。如果您希望,您可以生成您自己的项目(您需要将域类从 `initial/grails-app/domain/` 复制到您的项目中),或者只需使用 `initial` 项目,按照指南来执行操作。

2.1 您需要提供什么

完成本指南,您需要提供以下内容

  • 在您手中的一些时间

  • 不错的文本编辑器或 IDE

  • 安装了 JDK 1.7 或更高版本且正确设置了 `JAVA_HOME`

2.2 如何完成指南

请按照以下步骤来开始操作

Grails 指南仓库包含两个文件夹

  • initial 初始项目。常常是简单的 Grails 应用程序,并增加了一些代码,以便您可以快速入门。

  • complete 一个完整的示例。这是根据指南提出的步骤执行操作并对 `initial` 文件夹应用这些更改后的结果。

若要完成此指南,请转到 `initial` 文件夹

  • 在目录中输入 cd 后转到 grails-guides/using-hal-with-json-views/initial

并按照后续部分中的说明操作。

如果您 cdgrails-guides/using-hal-with-json-views/complete 中,则可以直接转到完成的示例

3 运行应用程序

Grails 很好地支持 RESTful URL 映射,用于呈现域资源。在 `initial` 项目中,我们已经在我们的域类中注释了 `@Resource` 注释,这将生成 `RestfulController` 及关联的 URL 映射,将每个域类作为一个 restful 资源呈现。

grails-app/domain/com/example/Customer.groovy
package com.example

import grails.rest.*

@Resource(readOnly = true, uri='/api/customers') (1)
class Customer {
1 @Resource 注释接受多个可选参数,包括 URL 终端和格式选项,以及 API 是否只能呈现读取终端

向我们的域类添加这些注释为我们快速创建我们的 API 做好了准备。有关 `@Resource` 的更多信息,请参见 Grails 文档

要运行应用程序,请使用命令 ./gradlew bootRun,此命令将在端口 8080 上启动应用程序。

既然 Grails 应用程序正在运行,我们可以通过 @Resource 注解尝试 Grails 为我们生成的 API。发送 GET 请求至 /api/products 以检索产品列表。

curl -H "Accept: application/json" localhost:8080/api/products
[
  {
    "id": 1,
    "category": {
      "id": 1
    },
    "inventoryId": "CLOTH001",
    "name": "Cargo Pants",
    "price": 15.00
  },
  {
    "id": 2,
    "category": {
      "id": 1
    },
    "inventoryId": "CLOTH002",
    "name": "Sweater",
    "price": 12.00
  },
  {
    "id": 3,
    "category": {
      "id": 1
//...
]

在此,发送至 /api/orders/1 的 GET 请求返回 ID 为 1 的 Order

curl -H "Accept: application/json" localhost:8080/api/orders/1
{
   "id" : 1,
   "shippingAddress" : {
      "id" : 1
   },
   "shippingCost" : 13.54,
   "orderId" : "0A12321",
   "orderPlaced" : "2017-02-08T09:10:36Z",
   "products" : [
      {
         "id" : 11
      },
      {
         "id" : 6
      },
      {
         "id" : 1
      }
   ],
   "customer" : {
      "id" : 1
   }
}
由于我们在 @Resource 注解中指定了 readOnly = true,Grails 不会为更新/创建/删除操作生成端点。这对本指南中的步骤来说已经足够,但你可以移除 readOnly 属性(或将其设为 false)以启用“写入”操作。

4 构建我们的 API

Grails 呈现的默认 JSON 有一个好的开端,但它不一定表示我们希望在公开 API 中表述的详细信息。JSON 视图 让我们利用 Groovy 的 StreamingJsonBuilder 使用静态编译的 Groovy 视图来呈现数据。JSON 视图提供了一个基于 DSL 的强大工具,用于表示 API 中的 JSON 输出。

4.1 引入 JSON 视图

这是一个 JSON 视图的小示例

json.message {
    hello "world"
}

呈现时,此 JSON 视图将生成以下输出

{"message":{ "hello":"world"}}

JSON 视图还支持 model,它引用传递到视图中的数据,如下所示

model {
    String message
}
json.message {
    hello message
}

JSON 视图是具有 gson 文件扩展名的 Groovy 文件,它们驻留在 grails-app/views 目录中,就像 GSP 视图一样。它们被解析到与视图目录同名的控制器中(同样与 GSP 视图一样,这是约定俗成的做法)。

4.2 自定义 API

让我们创建一个 JSON 视图来自定义来自 /api/orders/$id 端点的输出。现在,默认 JSON 呈现器包括所有关联对象的 ID。但是,我们不想公开 shippingAddress 属性的 id(它是 Address 域类的一个实例) - 它不是 API 中公开的域资源,对于 API 用户而言,它仅作为 OrderCustomer 的一部分具有相关性。理想情况下,我们希望将 shippingAddress 字段包含在 order API 的 JSON 输出中。

此外,我们希望将 orderId 属性表示为 order 的 id,而不是数据库中的实际 id。

grails-app/views 下创建一个名为 order 的新目录

$ mkdir grails-app/views/order
如果你熟悉 Grails 的视图解析,你可能会认为我们需要创建一个 OrderController 才能从 order 目录中使用视图。我们确实可以这样做,然而,由于我们正在我们的域类上使用 @Resource 注解,Grails 将生成为我们生成一个关联的 OrderController(它又将从 RestfulController 继承)。因此,此时,我们不需要为 Order 类创建控制器。

创建名为 show.gson 的新 JSON 视图。它将解析为控制器中的 show 操作,就像 show.gsp 页面在常规 Grails 应用程序中所做的那样。使用以下内容编辑新视图

grails-app/views/order/show.gson
import com.example.Order

model {
    Order order
}
json {
    id order.orderId
    shippingCost order.shippingCost
    date order.orderPlaced.format('M-dd-yyy') (1)

    shippingAddress { (2)
        street order.shippingAddress.street
        street2 order.shippingAddress.street2
        city order.shippingAddress.city
        state order.shippingAddress.state
        zip order.shippingAddress.zip
    }

    products order.products.collect { [id: it.id] } (3)

    customer {
        id order.customer.id
    }
}
1 请注意,我们正在 Date 类中使用 Groovy 的 format 方法自定义 orderPlaced 属性的格式
2 这里我们使用 Address 类的字段填充我们的 shippingAddress
3 请注意,我们正在通过 collect 方法遍历集合 (order.products) 并返回一个映射 - 这将创建一个对象的 JSON 数组

现在,如果您向 /api/orders/1 发出请求,您应该会看到以下输出

curl -H "Accept: application/json" localhost:8080/api/orders/1
{
    id: "0A12321",
    shippingCost: 13.54,
    date: "2-08-2017",
    shippingAddress: {
        street: "321 Arrow Ln",
        street2: null,
        city: "Chicago",
        state: "IL",
        zip: 646465
    },
    products: [
            {
                id: 11
            },
            {
                id: 1
            },
            {
                id: 6
            }
    ],
    customer: {
        id: 1
    }
}

让我们为我们的 Customer 域类创建另一个 JSON 视图。在 grails-app/views 下创建一个名为 customer 的新目录,并创建一个新的 JSON 视图 show.gson

$ mkdir grails-app/views/customer

创建名为 show.gson 的新 JSON 视图。它将解析为控制器中的 show 操作,就像 show.gsp 页面在常规 Grails 应用程序中所做的那样。使用以下内容编辑新视图

grails-app/views/customer/show.gson
import com.example.Customer

model {
    Customer customer
}
json {
    id customer.id
    firstName customer.firstName
    lastName customer.lastName
    fullName "${customer.firstName} ${customer.lastName}"

    address {
        street customer.address.street
        street2 customer.address.street2
        city customer.address.city
        state customer.address.state
        zip customer.address.zip
    }

    orders customer.orders.collect { [id: it.id] }
}

5 通过 HAL 使我们的 API 可被发现

我们现在在某种程度上自定义了我们的 API,但我们有一些问题

  1. 由于为订单自定义了我们的 id 属性,因此对于客户端来说如何引用一个颗粒记录不再明确(因为我们的 API 仍然依赖于数据库 id)

  2. 我们公开的三个域类有许多仅公开 id 的关联 - 这意味着客户端需要发出一个新请求来获取关联对象(例如,使用订单的客户端将需要发出单独的请求来获取其产品的字段)的详细信息

  3. 我们的 API 相当不透明 - 如果没有文档,我们的 API 用户必须猜测端点才能到达关联记录。即使有文档,客户端可能也需要使用自定义代码来浏览我们的 API,而没有一个一致的标准要遵循。

HAL+JSON 标准的惯例可以帮助我们解决这些问题,并且 JSON 视图为 HAL 提供了一流的支持 - 让我们看看如何使用它。

链接是 HAL 标准中的关键。HAL 资源包括一个名为 _links 的特殊字段,其中包含一个 JSON 对象数组,定义到相关资源的链接。HAL 链接包含(至少)两条信息 - 一种关系和一个包含用于访问相关资源的 URL 的 href。还可以包含其他元数据。

这里有一个 JSON 正文示例,其中包含一个 _links 字段和两个链接

{
    title: "Groovy Recipes",
    author: "Scott Davis",
    pages: 100,

    "_links": {
        "self": {
            "href": "http://localhost:8080/book/show/1",    (1)
        },
        "author": {
            "href": "http://localhost:8080/author/show/1", (2)
        }
    }
}
1 self 是每个 HAL 资源都应该包含的一个特殊链接 - 它指定返回到当前资源的 URL。
2 这里我们定义了一个名为 author 的自定义链接,它指定 author 字段的 URL

当客户端访问 HAL 资源时,可以在 _links 字段中表示关系处“浏览”而无需客户端知道 API 端点的确切组成。

//examples are using the fetch API: https://mdn.org.cn/en-US/docs/Web/API/Fetch_API

//retrieve a book instance from the API
fetch("http://localhost:8080/api/book/1").then(function(response) {
    return response.json();
}).then(function(data) {
    this.book = data;
});

//retrieve book's author using links
var author;
fetch(book._links.author.href).then(function(response) {
    return response.json();
}).then(function(data) {
    author = data;
});

JSON 视图实现了一个 Groovy 特性 HalView,它公开了带有用于输出符合 HAL 标准的 JSON 的多个方法的 hal 帮助器。其中之一是 links 方法。

model {
    Order order
}
json {
    hal.links(order)
    //...
}

在我们的域资源上调用 hal.links() 将产生以下 JSON 输出

{
    _links: {
        self: {
            href: "http://localhost:8080/api/orders/1",
            hreflang: "en_US",
            type: "application/hal+json"
        }
    }
}

让我们编辑我们的 order/show.gson 视图,以包括一个 self 链接和到相关客户的链接

grails-app/views/order/show.gson
import com.example.Order

model {
    Order order
}
json {
    hal.links(self: order, customer: order.customer) (1)

    id order.orderId
    shippingCost order.shippingCost
    date order.orderPlaced.format('M-dd-yyy')

    shippingAddress {
        street order.shippingAddress.street
        street2 order.shippingAddress.street2
        city order.shippingAddress.city
        state order.shippingAddress.state
        zip order.shippingAddress.zip
    }

    products order.products.collect { [id: it.id] }

    customer {
        id order.customer.id
    }
}
1 links 方法可以采用域资源实例,或要链接的链接名称和对象的映射。
{
    _links: {
        self: {
            href: "http://localhost:${serverPort}/api/orders?id=1",
            hreflang: "en",
            type: "application/hal+json"
        },
        customer: {
            href: "http://localhost:${serverPort}/api/customers?id=1",
            hreflang: "en",
            type: "application/hal+json"
        }
    },
    id: "0A12321",
    shippingCost: 13.54,
    date: "2-08-2017",
    shippingAddress: {
        street: "321 Arrow Ln",
        street2: null,
        city: "Chicago",
        state: "IL",
        zip: 646465
    },
    products: [
            {
                id: 11
            },
            {
                id: 1
            },
            {
                id: 6
            }
    ],
    customer: {
        id: 1
    }
}
    //...
}

5.2 使用模板呈现集合

Order 包含与 Product 的一对多关系,现在我们的 API 返回一个简单的 ID 列表来表示订单中的产品。理想情况下,我们还希望包含到这些产品的链接,以便 API 的客户端可以通过跟踪我们提供的链接来检索每个产品的详细信息。我们可以使用 JSON 视图的模板功能。

grails-app/views/order 目录中,创建一个名称为 _product.gson 的新 JSON 模板

grails-app/views/order/_product.gson
import com.example.Product

model {
  Product product
}
json {
  hal.links(product)

  id product.id
}

现在在我们的 order/show.gson 视图中,我们可以使用我们的新 _product 模板将 order.products 传递至 tmpl 帮助器

grails-app/views/order/show_v3.gson
import com.example.Order

model {
    Order order
}
json {
    hal.links(self: order, customer: order.customer)

    id order.orderId
    shippingCost order.shippingCost
    date order.orderPlaced.format('M-dd-yyy')

    shippingAddress {
        street order.shippingAddress.street
        street2 order.shippingAddress.street2
        city order.shippingAddress.city
        state order.shippingAddress.state
        zip order.shippingAddress.zip
    }

    products tmpl.product(order.products) (1)
}
1 tmpl 帮助器会将一个方法名称解析为当前视图目录中的一个模板 - 例如,tmpl.product 将解析为 /order/_product.gson。如果你想访问当前目录之外的模板,可以使用一个绝对路径(相对于 views 目录)作为字符串:tmpl."/customer/order"() 将解析为 grails-app/views/customer/_order.gson
有关在 JSON 视图中使用模板的更多信息,请参阅 Grails 视图文档

http://localhost:8080/api/orders/1 发出请求,你应该会看到 products 数组中每个产品的 _links

{
    _links: {
        self: {
            href: "http://localhost:${serverPort}/api/orders?id=1",
            hreflang: "en",
            type: "application/hal+json"
        },
        customer: {
            href: "http://localhost:${serverPort}/api/customers?id=1",
            hreflang: "en",
            type: "application/hal+json"
        }
    },
    id: "0A12321",
    shippingCost: 13.54,
    date: "2-08-2017",
    shippingAddress: {
        street: "321 Arrow Ln",
        street2: null,
        city: "Chicago",
        state: "IL",
        zip: 646465
    },
    products: [
        {
            _links: {
                self: {
                    href: "http://localhost:${serverPort}/api/products/11",
                    hreflang: "en",
                    type: "application/hal+json"
                }
            },
            id: 11
        },
        {
            _links: {
                self: {
                    href: "http://localhost:${serverPort}/api/products/6",
                    hreflang: "en",
                    type: "application/hal+json"
                }
            },
            id: 6
        },
        {
            _links: {
                self: {
                    href: "http://localhost:${serverPort}/api/products/1",
                    hreflang: "en",
                    type: "application/hal+json"
                }
            },
            id: 1
        }
    ]
}

让我们对 客户的订单 - 创建一个新模板在 `grails-app/views/customer/_order.gson` 中使用相同的技术

grails-app/views/customer/_order.gson
import com.example.Order

model {
    Order order
}
json {
    hal.links(order)

    id order.id
}

编辑 customer/show.gson 视图以使用新的 _order 模板

让我们对 客户的订单 - 创建一个新模板在 `grails-app/views/customer/_order.gson` 中使用相同的技术

grails-app/views/customer/show.gson
import com.example.Customer

model {
    Customer customer
}
json {
    id customer.id
    firstName customer.firstName
    lastName customer.lastName
    fullName "${customer.firstName} ${customer.lastName}"

    address {
        street customer.address.street
        street2 customer.address.street2
        city customer.address.city
        state customer.address.state
        zip customer.address.zip
    }

    orders tmpl.order(customer.orders)
}

5.3 嵌入关联对象

我们来看一下我们的 Product 域资源。Product 具有与 CategorybelongsTo 关系,它在我们的默认 JSON 输出中以一个包含类别 ID 的简单对象来表示

{
    id: 1,
    category: {
        id: 1
    },
    inventoryId: "CLOTH001",
    name: "Cargo Pants",
    price: 15
}

我们再次希望让 API 使用者更容易获取产品的类别。我们有多个选项

  1. 我们可以在 JSON 视图中直接包含 category 详细信息:这种方法混淆了我们的 API 中 ProductCategory 资源之间的界限 - 它给客户端(错误的)印象:认为 category.nameProduct 的属性,而不是一个本身就是 API 资源。

  2. 我们可以提供到该类别的链接:此方法要求客户端发出新的请求来获取类别详细信息,并且很可能大多数客户端在同一请求中需要产品和类别详细信息。

您可能还记得,在订单的shippingAddress的情况下,我们使用这两种方法中的第一种(在 JSON 视图中包括关联对象的详细信息)——这是因为我们的 API 中未将Address公开为资源,因此就我们的 API 而言,Address实际上是OrdershippingAddress)或Customeraddress)的一部分。

HAL 指定一个_embedded属性来以嵌套格式表示资源间的关联关系。使用嵌入方法,我们可以将Category包含在同一个 HAL+JSON 响应中,但该类别将处于一个单独的元素中,以阐明这些是单独的资源。

JSON 视图提供了一个embedded方法(通过hal帮助程序)将在我们的 JSON 视图中生成一个_embedded元素。它将包含嵌入对象中默认 JSON 输出以及每个对象的_self链接。我们用这个在我们的产品输出中嵌入类别。

grails-app/views下创建一个名为product的新目录

$ mkdir grails-app/views/product/

在此目录下创建一个名为show.gson的新 JSON 视图

grails-app/views/product/show.gson
import com.example.Product

model {
    Product product
}
json {
    hal.links(product)
    hal.embedded(category: product.category) (1)

    id product.inventoryId
    name product.name
    price product.price
}
1 我们向embedded方法传入一个元素名称(在本例中为category)到嵌入对象的映射(product.category
{
  "_links": {
    "self": {
      "href": "http://localhost:${serverPort}/api/products/1",
      "hreflang": "en",
      "type": "application/hal+json"
    }
  },
  "_embedded": {
    "category": {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/categories/1",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Clothing",
      "version": 0
    }
  },
  "id": "CLOTH001",
  "name": "Cargo Pants",
  "price": 15.00
}

在上一个代码片段中,category对象包括我们的Category资源的默认 JSON 渲染程序输出,以及一个_self链接,以便在需要时客户端可以直接请求类别。

让我们使用order/show.gson视图上的embedded 方法,并嵌入 `order.customer` 资源。

grails-app/views/order/show.gson
import com.example.Order

model {
    Order order
}
json {
    hal.links(self: order, customer: order.customer)
    hal.embedded(customer: order.customer) (1)
    }
  },
  "_embedded": {
    "customer": {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/customers/1",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "firstName": "Peter",
      "lastName": "River",
      "version": 0

现在我们 API 的客户端可以访问嵌入资源的详细信息,而无需发出其他请求。

//examples are using the fetch API: https://mdn.org.cn/en-US/docs/Web/API/Fetch_API

//retrieve an order instance from the API
fetch("http://localhost:8080/api/orders/1").then(function(response) {
    return response.json();
}).then(function(data) {
    this.order = data;
});

var customer = this.order._embedded.customer;

console.log("Order ID: " + this.order.id);
console.log("Customer: " + customer.firstName + " " + customer.lastName);

//retrieve a product instance from the API
fetch("http://localhost:8080/api/products/1").then(function(response) {
    return response.json();
}).then(function(data) {
    this.product = data;
});

console.log("Product: " + this.product.name);
console.log("Category:" + this.order._embedded.category.name);

5.4 分页结果

HAL 标准指定的另一个约定是分页。在提供资源列表时,_links元素可以提供firstprevnextlast链接,可用于浏览资源列表。

hal帮助程序提供一个paginate方法,该方法将生成这些链接并处理资源的分页。此方法要求我们 JSON 视图的model中的更多信息,以便跟踪当前偏移量、每页的最大记录数以及资源总数。为了做到这一点,我们需要创建一个控制器,以便我们可以传入所需的模型参数。

让我们在我们的Product资源上使用 HAL 分页链接。

因为我们将创建自己的 ProductController 来提供分页所需的参数,我们需要移除 Product 领域类中一直使用的 @Resource 注释。编辑 grails-app/domain/com/example/Product.groovy

grails-app/domain/com/example/Product.groovy
package com.example

class Product {

    String name
    String inventoryId

    BigDecimal price

    static belongsTo = [ category : Category ]
}
在开发 RESTful API 时,你通常会发现 @Resource 生成的控制器和 URL 映射是入门的好方法,但有时你需要更多地控制 API - 这时候生成你自己的 RestfulController 是一个好的解决方案。

现在,使用 create-restful-controller 命令(由 rest-api 配置文件提供)创建一个新的 RestfulController

$ ./grailsw create-restful-controller com.example.ProductController

用如下内容编辑这个新控制器

grails-app/controllers/com/example/ProductController.groovy
package com.example

import grails.rest.RestfulController

class ProductController extends RestfulController<Product> {
    static responseFormats = ['json']

    ProductController() {
        super(Product)
    }

    @Override
    def index(Integer max) {
        params.max = Math.min(max ?: 10, 100)

        return [
                productList : listAllResources(params), (1)
                productCount: countResources(),     (2)
                max         : params.max,        (3)
                offset      : params.int("offset") ?: 0, (4)
                sort        : params.sort,  (5)
                order       : params.order  (6)
        ]
    }

    @Override
    boolean getReadOnly() {
        return true
    }
}
1 listAllResource()RestfulController 提供,用于返回所有领域资源的列表 - 你可以覆盖此方法以控制此列表的生成方式
2 countResources() 是另一个 RestfulController 方法 - 再次声明,你可以覆盖实现以适应你的 API
3 每页结果总数
4 偏移量(用于计算当前页)
5 排序属性
6 排序方向

最后,我们需要编辑 UrlMappings 以创建以前使用 @Resource 注释生成的 REST 端点。Grails 在 URL 映射中支持 resource 属性,它将自动生成这些 URL。编辑 UrlMappings.groovy 并将以下规则添加到 mappings

grails-app/controller/com/example/UrlMappings.groovy
"/api/products"(resources: "product")

现在,我们可以使用分页创建新的 JSON 视图。在 grails-app/views/product 下创建以下视图和模板

grails-app/views/product/index.gson
import com.example.Product

model {
    Iterable<Product> productList (1)
    Integer productCount (2)
    Integer max
    Integer offset
    String sort
    String order
}

json {
    hal.paginate(Product, productCount, offset, max, sort, order) (3)
    products tmpl.product(productList ?: [])
    count productCount
    max max
    offset offset
    sort sort
    order order
}
1 产品资源列表
2 来自我们控制器的分页参数
3 在这里,我们将分页参数传递给 paginate 方法,它将生成 HAL 分页链接
grails-app/views/product/_product.gson
import com.example.Product

model {
    Product product
}
json {
    hal.links(product)

    name product.name
    id product.inventoryId
    price product.price
    category product.category.name
}
"""
{
  "_links": {
    "self": {
      "href": "http://localhost:${serverPort}/api/products?offset=0&max=10",
      "hreflang": "en",
      "type": "application/hal+json"
    },
    "first": {
      "href": "http://localhost:${serverPort}/api/products?offset=0&max=10",
      "hreflang": "en"
    },
    "next": {
      "href": "http://localhost:${serverPort}/api/products?offset=10&max=10",
      "hreflang": "en"
    },
    "last": {
      "href": "http://localhost:${serverPort}/api/products?offset=10&max=10",
      "hreflang": "en"
    }
  },
  "products": [
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/1",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Cargo Pants",
      "id": "CLOTH001",
      "price": 15.00,
      "category": "Clothing"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/2",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Sweater",
      "id": "CLOTH002",
      "price": 12.00,
      "category": "Clothing"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/3",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Jeans",
      "id": "CLOTH003",
      "price": 15.00,
      "category": "Clothing"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/4",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Blouse",
      "id": "CLOTH004",
      "price": 18.00,
      "category": "Clothing"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/5",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "T-Shirt",
      "id": "CLOTH005",
      "price": 10.00,
      "category": "Clothing"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/6",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Jacket",
      "id": "CLOTH006",
      "price": 20.00,
      "category": "Clothing"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/7",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Bookcase",
      "id": "FURN001",
      "price": 40.00,
      "category": "Furniture"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/8",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Coffee Table",
      "id": "FURN002",
      "price": 50.00,
      "category": "Furniture"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/9",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Vanity",
      "id": "FURN003",
      "price": 90.00,
      "category": "Furniture"
    },
    {
      "_links": {
        "self": {
          "href": "http://localhost:${serverPort}/api/products/10",
          "hreflang": "en",
          "type": "application/hal+json"
        }
      },
      "name": "Table Saw",
      "id": "TOOL001",
      "price": 120.00,
      "category": "Tools"
    }
  ],
  "count": 13,
  "max": 10,
  "offset": 0,
  "sort": null,
  "order": null
}

next 链接 http://localhost:8080/product/index?offset=10&max=10 发出请求,你将看到下一页结果。由于示例数据中的资源数量较少,因此只有 2 页 - 尝试将请求中的 max 参数更改为 4 - 现在你将检索其他页面以反映较小的页面大小。

如果你愿意,可以重复这些步骤为其他领域资源(如 OrderCustomer)启用分页。

5.5 自定义 MIME 类型

HAL 资源可以声明一个自定义的“MIME 类型”(或“内容类型”),以便客户端使用它与 API 交互。Grails 在默认的 application.yml 中包含两个通用的 HAL MIME 类型

grails-app/conf/application.yml
    accept:
        header:
            userAgents:
                - Gecko
                - WebKit
                - Presto
                - Trident
types:
    json:
      - application/json
      - text/json
    hal:
      - application/hal+json
      - application/hal+xml
    xml:
      - text/xml
      - application/xml

如果您愿意,可以通过向此配置添加条目来为您的 API 指定自定义 MIME 类型

grails-app/conf/application.yml
        xml:
          - text/xml
          - application/xml
        atom: application/atom+xml
        css: text/css
        csv: text/csv
        js: text/javascript
        rss: application/rss+xml
        text: text/plain
        all: '*/*'
        inventory: "application/vnd.com.example.inventory+json" (1)
urlmapping:
    cache:
        maxsize: 1000
1 指定一个称为 inventory 的 MIME 类型,然后向它提供类型规范(根据惯例,vnd 表示“供应商”MIME 类型)

现在,您可以在 JSON 视图中使用 type 帮助程序方法使用此自定义 MIME 类型

grails-app/views/product/show.gson
import com.example.Product

model {
    Product product
}

json {
    hal.links(product)
    hal.embedded(category: product.category)
    hal.type("inventory") (1)
    id product.inventoryId
    name product.name
    price product.price
}
1 hal.type() 方法接受一个字符串来识别 application.yml 文件中的自定义 MIME 类型,或接受一个作为字符串的显式 MIME 规范

http://localhost:8080/api/products/1 发出请求,以查看自定义内容类型

$ curl -i localhost:8080/product/1

HTTP/1.1 200
X-Application-Context: application:development
Content-Type: application/vnd.com.example.inventory+json;charset=UTF-8 (1)
Transfer-Encoding: chunked
Date: Sun, 05 Feb 2017 01:51:08 GMT

        def result = render(view: "/product/show", model:[product: product])

        then:
        result.json._embedded
    }
}
1 注意 Content-Type 标头中的新 MIME 类型

6 探索 API

HAL+JSON 是一种用于构建 API 输出的灵活且强大的规范。无论第三方整合、您自己的网络应用还是原生应用是 API 的客户端,它都可以以一种有效且一致的方式浏览您的资源。

有很多有用的工具用于开发和测试 HAL+JSON API。如果您使用 Google Chrome 浏览器,请尝试安装 JSONView 插件,然后使用浏览器导航至 http://localhost:8080/api/products。此插件将以一种友好的格式呈现 JSON,并允许您在浏览器中关注资源之间的链接。

JSONViewer
图 1. Google Chrome 的 JSONViewer 插件

Postman 是一款基于 Google Chrome 的应用,支持 macOS、Windows、Linux 和 Chrome 操作系统,是用于测试 API 的另一个强大工具。

Postman
图 2. Postman

7 您需要 Grails 的帮助吗?

Object Computing, Inc. (OCI) 赞助了本指南的创建。涵盖多种咨询和支持服务。

OCI 是 Grails 的起源

认识团队