Go
JSON
proxy
Hypermedia
goa
Go3Day 1

一発でセラーの中身をぶっこぬく(あるいはgoaでHAL+JSON)

はじめに

Goでweb APIを作る際にはgoaが便利だが、デザイン時にひと工夫することでAPIの柔軟性が増す。ここではgoaの例としてしばしば用いられるワインセラーのAPIを基に、HAL+JSONを取り入れることでセラーの中身すべてを1リクエストで取得する。

セラーの中身をすべて

オリジナルのデザインのAPIでセラーの中身すべて、すなわち全アカウントの全ボトルの情報を取得するにはどうすればいいだろうか?

これは /cellar/accounts を起点に各アカウントからアカウント毎のボトルの一覧、ボトルの詳細へと辿って行くことで実現できる。

$ curl http://localhost:8081/cellar/accounts
[{"href":"/cellar/accounts/1","id":1,"name":"account 1"},{"href":"/cellar/accounts/2","id":2,"name":"account 2"}]
$ curl http://localhost:8081/cellar/accounts/1
{"created_at":"0001-01-01T00:00:00Z","created_by":"","href":"/cellar/accounts/1","id":1,"name":"account 1"}
$ curl http://localhost:8081/cellar/accounts/2
{"created_at":"0001-01-01T00:00:00Z","created_by":"","href":"/cellar/accounts/2","id":2,"name":"account 2"}
$ curl http://localhost:8081/cellar/accounts/1/bottles
[{"account":{"href":"/cellar/accounts/1","id":1,"name":"account 1"},"href":"/cellar/accounts/1/bottles/100","id":100,"links":{"account":{"href":"/cellar/accounts/1","id":1}},"name":"Number 8","rating":4,"varietal":"Merlot","vineyard":"Asti Winery","vintage":2012},{"account":{"href":"/cellar/accounts/1","id":1,"name":"account 1"},"href":"/cellar/accounts/1/bottles/101","id":101,"links":{"account":{"href":"/cellar/accounts/1","id":1}},"name":"Mourvedre","rating":3,"varietal":"Mourvedre","vineyard":"Rideau","vintage":2012},{"account":{"href":"/cellar/accounts/1","id":1,"name":"account 1"},"href":"/cellar/accounts/1/bottles/102","id":102,"links":{"account":{"href":"/cellar/accounts/1","id":1}},"name":"Blue's Cuvee","rating":5,"varietal":"Cabernet Franc with Merlot, Malbec, Cabernet Sauvignon and Syrah","vineyard":"Longoria","vintage":2012}]
$ curl http://localhost:8081/cellar/accounts/2/bottles
[{"account":{"href":"/cellar/accounts/2","id":2,"name":"account 2"},"href":"/cellar/accounts/2/bottles/200","id":200,"links":{"account":{"href":"/cellar/accounts/2","id":2}},"name":"Blackstone Merlot","rating":3,"varietal":"Merlot","vineyard":"Blackstone","vintage":2012},{"account":{"href":"/cellar/accounts/2","id":2,"name":"account 2"},"href":"/cellar/accounts/2/bottles/201","id":201,"links":{"account":{"href":"/cellar/accounts/2","id":2}},"name":"Wild Horse","rating":4,"varietal":"Pinot Noir","vineyard":"Wild Horse","vintage":2010}]
$ curl http://localhost:8081/cellar/accounts/1/bottles/100
{"account":{"href":"/cellar/accounts/1","id":1,"name":"account 1"},"href":"/cellar/accounts/1/bottles/100","id":100,"links":{"account":{"href":"/cellar/accounts/1","id":1}},"name":"Number 8","rating":4,"varietal":"Merlot","vineyard":"Asti Winery","vintage":2012}
$ curl http://localhost:8081/cellar/accounts/1/bottles/101
{"account":{"href":"/cellar/accounts/1","id":1,"name":"account 1"},"href":"/cellar/accounts/1/bottles/101","id":101,"links":{"account":{"href":"/cellar/accounts/1","id":1}},"name":"Mourvedre","rating":3,"varietal":"Mourvedre","vineyard":"Rideau","vintage":2012}
$ curl http://localhost:8081/cellar/accounts/1/bottles/102
{"account":{"href":"/cellar/accounts/1","id":1,"name":"account 1"},"href":"/cellar/accounts/1/bottles/102","id":102,"links":{"account":{"href":"/cellar/accounts/1","id":1}},"name":"Blue's Cuvee","rating":5,"varietal":"Cabernet Franc with Merlot, Malbec, Cabernet Sauvignon and Syrah","vineyard":"Longoria","vintage":2012}
$ curl http://localhost:8081/cellar/accounts/2/bottles/200
{"account":{"href":"/cellar/accounts/2","id":2,"name":"account 2"},"href":"/cellar/accounts/2/bottles/200","id":200,"links":{"account":{"href":"/cellar/accounts/2","id":2}},"name":"Blackstone Merlot","rating":3,"varietal":"Merlot","vineyard":"Blackstone","vintage":2012}
$ curl http://localhost:8081/cellar/accounts/2/bottles/201
{"account":{"href":"/cellar/accounts/2","id":2,"name":"account 2"},"href":"/cellar/accounts/2/bottles/201","id":201,"links":{"account":{"href":"/cellar/accounts/2","id":2}},"name":"Wild Horse","rating":4,"varietal":"Pinot Noir","vineyard":"Wild Horse","vintage":2010}

しかし、この方法では必要となるリクエストの数が10と多い。

HAL+JSON

HAL+JSONはJSONに関連するリソースへのリンクを表す_linksプロパティや、リソースの埋め込みを表す_embedプロパティを定義する。

例えばあるアカウントのボトル一覧のリソースは自身へのリンクself、その所有者であるアカウントへのリンクaccount、要素である各ボトルへのリンクmembers_linksプロパティに持つことで表現される。

{
  "_links": {
    "account": {
      "href": "/accounts/1"
    },
    "members": [
      {
        "href": "/accounts/1/bottles/100"
      },
      {
        "href": "/accounts/1/bottles/101"
      },
      {
        "href": "/accounts/1/bottles/102"
      }
    ],
    "self": {
      "href": "/accounts/1/bottles"
    }
  }
}

goaでHAL+JSONを返すには、各リソースのメディアタイプに対して、リンクを表すタイプ(この場合はBottleCollectionLinks)を定義する。

var BottleCollection = MediaType("application/vnd.bottle-collection+json", func() {
    Attributes(func() {
        Attribute("_links", BottleCollectionLinks)

        Required("_links")
    })

    View("default", func() {
        Attribute("_links")
    })
})
var BottleCollectionLinks = Type("BottleCollectionLinks", func() {
    Attribute("self", HALLink)
    Attribute("account", HALLink)
    Attribute("members", ArrayOf(HALLink))

    Required("self", "account", "members")
})
var HALLink = Type("HALLink", func() {
    Attribute("href", String)

    Required("href")
})

HAL+JSON化したワインセラーのAPIのソースコードは https://github.com/ichiban/cellar にある。このデザインではオリジナルのAPI同様、すべての情報を一度に取得するというユースケースは想定していない。

セラーの中身をすべて(1リクエストで)

jesiというリバース・プロキシがある。なぜあるかというと作ったからだが、jesiを経由させることで任意のリンクされたリソースを埋め込むことができる。

$ jesi -port 8080 -backend http://localhost:8081/accounts
INFO[0000] Added a backend                               backend="http://localhost:8081/accounts" queue=healthy
INFO[0000] Start a server                                max=67108864 node=_6a40e7d5-e66b-4cfd-a117-d458effda69a port=8080 verbose=false version=

これを使えば、アカウント一覧の取得時に、各アカウントmemberのボトル一覧bottlesの各ボトルmembersを埋め込むよう指定することですべての情報を一度に取得できる。

$ curl -H 'With: members.bottles.members' http://localhost:8080/accounts | jq .
{
  "_embedded": {
    "members": [
      {
        "_embedded": {
          "bottles": {
            "_embedded": {
              "members": [
                {
                  "_links": {
                    "collection": {
                      "href": "/accounts/1/bottles"
                    },
                    "self": {
                      "href": "/accounts/1/bottles/100"
                    }
                  },
                  "id": 100,
                  "name": "Number 8",
                  "rating": 4,
                  "varietal": "Merlot",
                  "vineyard": "Asti Winery",
                  "vintage": 2012
                },
                {
                  "_links": {
                    "collection": {
                      "href": "/accounts/1/bottles"
                    },
                    "self": {
                      "href": "/accounts/1/bottles/101"
                    }
                  },
                  "id": 101,
                  "name": "Mourvedre",
                  "rating": 3,
                  "varietal": "Mourvedre",
                  "vineyard": "Rideau",
                  "vintage": 2012
                },
                {
                  "_links": {
                    "collection": {
                      "href": "/accounts/1/bottles"
                    },
                    "self": {
                      "href": "/accounts/1/bottles/102"
                    }
                  },
                  "id": 102,
                  "name": "Blue's Cuvee",
                  "rating": 5,
                  "varietal": "Cabernet Franc with Merlot, Malbec, Cabernet Sauvignon and Syrah",
                  "vineyard": "Longoria",
                  "vintage": 2012
                }
              ]
            },
            "_links": {
              "account": {
                "href": "/accounts/1"
              },
              "members": [
                {
                  "href": "/accounts/1/bottles/100"
                },
                {
                  "href": "/accounts/1/bottles/101"
                },
                {
                  "href": "/accounts/1/bottles/102"
                }
              ],
              "self": {
                "href": "/accounts/1/bottles"
              }
            }
          }
        },
        "_links": {
          "bottles": {
            "href": "/accounts/1/bottles"
          },
          "collection": {
            "href": "/accounts"
          },
          "self": {
            "href": "/accounts/1"
          }
        },
        "created_at": "2017-11-18T09:34:18Z",
        "created_by": "",
        "id": 1,
        "name": "account 1"
      },
      {
        "_embedded": {
          "bottles": {
            "_embedded": {
              "members": [
                {
                  "_links": {
                    "collection": {
                      "href": "/accounts/2/bottles"
                    },
                    "self": {
                      "href": "/accounts/2/bottles/200"
                    }
                  },
                  "id": 200,
                  "name": "Blackstone Merlot",
                  "rating": 3,
                  "varietal": "Merlot",
                  "vineyard": "Blackstone",
                  "vintage": 2012
                },
                {
                  "_links": {
                    "collection": {
                      "href": "/accounts/2/bottles"
                    },
                    "self": {
                      "href": "/accounts/2/bottles/201"
                    }
                  },
                  "id": 201,
                  "name": "Wild Horse",
                  "rating": 4,
                  "varietal": "Pinot Noir",
                  "vineyard": "Wild Horse",
                  "vintage": 2010
                }
              ]
            },
            "_links": {
              "account": {
                "href": "/accounts/2"
              },
              "members": [
                {
                  "href": "/accounts/2/bottles/200"
                },
                {
                  "href": "/accounts/2/bottles/201"
                }
              ],
              "self": {
                "href": "/accounts/2/bottles"
              }
            }
          }
        },
        "_links": {
          "bottles": {
            "href": "/accounts/2/bottles"
          },
          "collection": {
            "href": "/accounts"
          },
          "self": {
            "href": "/accounts/2"
          }
        },
        "created_at": "2017-11-18T09:34:18Z",
        "created_by": "",
        "id": 2,
        "name": "account 2"
      }
    ]
  },
  "_links": {
    "members": [
      {
        "href": "/accounts/1"
      },
      {
        "href": "/accounts/2"
      }
    ],
    "self": {
      "href": "/accounts"
    }
  }
}

おわりに

goaでHAL+JSONを返す方法を示し、それがAPIに柔軟性を与え、ワインセラーのAPIの例ですべての情報を1リクエストで取得できることを示した。

ところで飲酒は喫煙と並んで現代社会が克服すべき悪習である。