読者です 読者をやめる 読者になる 読者になる

goa の API デザインの書き方 後編 (Resource と Payload)

golang Go言語 goadesign

概要

goa の API デザインについて,デザインを定義する4つの要素の概要説明の後半です.

  • APIAPI サーバの定義
  • ✓ MediaType … レスポンスデータの定義
  • Resource … APIが管理するデータへのアクセス方法 / エンドポイントなどを定義
  • Payload … API に送信するデータの定義

今回は残りの Resource と Payload の説明です.


準備:API サンプル

おなじみの最小構成サンプル.全体の把握のために貼っておきます.

package design                                     // The convention consists of naming the design
                                                   // package "design"
import (
        . "github.com/goadesign/goa/design"        // Use . imports to enable the DSL
        . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("cellar", func() {                     // API defines the microservice endpoint and
        Title("The virtual wine cellar")           // other global properties. There should be one
        Description("A simple goa service")        // and exactly one API definition appearing in
        Scheme("http")                             // the design.
        Host("localhost:8080")
})

var _ = Resource("bottle", func() {                // Resources group related API endpoints
        BasePath("/bottles")                       // together. They map to REST resources for REST
        DefaultMedia(BottleMedia)                  // services.

        Action("show", func() {                    // Actions define a single API endpoint together
                Description("Get bottle by id")    // with its path, parameters (both path
                Routing(GET("/:bottleID"))         // parameters and querystring values) and payload
                Params(func() {                    // (shape of the request body).
                        Param("bottleID", Integer, "Bottle ID")
                })
                Response(OK)                       // Responses define the shape and status code
                Response(NotFound)                 // of HTTP responses.
        })
})

// BottleMedia defines the media type used to render bottles.
var BottleMedia = MediaType("application/vnd.goa.example.bottle+json", func() {
        Description("A bottle of wine")
        Attributes(func() {                         // Attributes define the media type shape.
                Attribute("id", Integer, "Unique bottle ID")
                Attribute("href", String, "API href for making requests on the bottle")
                Attribute("name", String, "Name of wine")
                Required("id", "href", "name")
        })
        View("default", func() {                    // View defines a rendering of the media type.
                Attribute("id")                     // Media types may have multiple views and must
                Attribute("href")                   // have a "default" view.
                Attribute("name")
        })
})

では本題です.

Resource定義

func Resource

リソースは Web API の操作の対象になるもの,操作の方法を定義していきます.

var _ = Resource("bottle", func() {                // Resources group related API endpoints
        BasePath("/bottles")                       // together. They map to REST resources for REST
        DefaultMedia(BottleMedia)                  // services.

        Action("show", func() {                    // Actions define a single API endpoint together
                Description("Get bottle by id")    // with its path, parameters (both path
                Routing(GET("/:bottleID"))         // parameters and querystring values) and payload
                Params(func() {                    // (shape of the request body).
                        Param("bottleID", Integer, "Bottle ID")
                })
                Response(OK)                       // Responses define the shape and status code
                Response(NotFound)                 // of HTTP responses.
        })
})

リソースには名前をつける必要があります.上記の例ではリソースを "bottle" と命名しています.

  • BasePath 関数はこのリソースにアクセスするための endpoint のパスの prefix 部分を定義できます.これは後で出てくる Action 関数の説明で一緒に説明します.
  • DefaultMedia 関数はこのリソースへの操作のレスポンスのデフォルトのメディアタイプを指定できます.

リソースの中心となるのは,Action です.Action はリソースに対して,複数定義することが出来ます.直感的には,API の endpoint とレスポンスの定義を行うものだと考えればいいでしょう.Action につける名前は,"add" とか,"create"とか,"list" とか "show" などリソースに対する操作を指定するものをつけることが多いようです.

ルーティングの指定

Routing は,API の endpoint を指定します.GET や POST,PUT,PATCH,DELETE などのHTTPメソッドと endpoint のアクセスパスを設定します.アクセスパスにはパスパラメータを含めることが出来ます.

Routing(GET("/:bottleID")) 

なら,endpoint /{bottleID} に GET メソッドでアクセスできることを指定しています. パラメータ bottleID については,後述のParams 関数の中でで受け付ける型などを指定します.

もし,Resource ですでに BasePath が設定されていれば,BasePath 関数で指定されているパスが prefix につきます. 上記の例では,BasePath("/bottles") で,Routing("/:bottleID") なので,endpoint は /bottles/{bottleID} となります.

Routing は要素を複数取れるので,

Routing(POST("/:id"), PATHC("/:id"))

のように POST でも PATCH メソッドでもアクセスできる endpoint を指定することも出来ます.

パラメータの指定

Params 関数は endpoint が受け付けるパラメータを列挙して定義します. Routing(/:bottleID) のようにパスパラメータで利用されたパラメータもここで定義します.

Routing(GET("/:bottleID")) 
Params(func() {                   
        Param("bottleID", Integer, "Bottle ID")
})

上の例では,パスパラメータで利用している bottleID は,Integer 型で,"Bottle ID"という説明 (これはドキュメントに記載される)が指定されています.

もし,パスパラメータとは別にクエリパラメータを指定したければ,他で利用しているパラメータと被らない名前をつけて,同じように定義を足してやればいいです.先ほどの例にワインのカテゴリ "category" を追加して "red" とか "white" とか "rose" みたいな文字列でワインの種類を指定させる例を下記に示します.

Routing(GET("/:bottleID")) 
Params(func() {                   
        Param("bottleID", Integer, "Bottle ID")
        Param("category", String, "Category")
})

category はクエリパラメータになるので,

curl -XGET localhost:8080/bottles/1?category=red

のように指定できます."category" を必須指定にしたければ Required("category")Params 関数の中で指定して下さい.(パスパラメータは何もしなくても必須要素になります)

パラメータにバリデーションを設定する

たとえば,bottleID は 0から127までの整数,category は "red" / "white" / "rose" のいずれかだったとしましょう. そのときに,これ以外の値がパラメータに入らないようにするには次のようにパラメータにバリデーションを追加できます.

Routing(GET("/:bottleID")) 
Params(func() {                   
        Param("bottleID", Integer, "Bottle ID", func() {
                Minimum(0)
                Maximum(127)
        })
        Param("category", String, "Category", func(){
                Enum("red", "white", "rose")
        })
})
バリデーション DSLの例 説明
最大/最小 Maximum(100) / Minimum(0) 数値 (Integer, Number) で利用できる
最大長/最小長 MaxLength(10) / MinLength(1) 文字列,配列で利用できる.文字列は rune 文字長でカウントされる
パターン Pattern("^foo") 正規表現を利用できる
列挙 Enum(3, 5, 7) / Emum("male", "female") 数値や文字列に対して列挙したものだけを受け付けるように出来る
定型 Format("date-time") / Format("email") / Format("ipv4") / Format("mac") goa で定型フォームとして用意されているものがいくつかあります

Payloadの設定方法

func Type

endpoint で受け付けるデータの形式を定義します.Payload を定義するには Type 関数を利用します. Params の設定方法と似たような形式なので,実際見てもらった方が理解しやすいかと思います.

BottlePayload = Type("BottlePayload", func() {
        Member("bottleID", Integer, "Bottle ID", func(){
                Minimum(0)
                Maximum(127)
        })
        Member("category", String, "Category", func(){
                Enum("red", "whilte", "rose")
                Default("red")
        })
        Member("comment", String, "Comment", func(){
                MaxLength(256)
        })
        Required("bottleID", "category")
})

このように,要素とバリデーションを設定することが出来ます.これは,下記のようなJSON データに対応します.

{
        "bottleID" : 3,
        "category" : "red",
        "comment" : "tasty!"
}

または,commentRequired ではないので,

{
        "bottleID" : 3,
        "category" : "red",
}

でも受け付けます.

Payload の Member には Default関数が設定できます.Default を指定しておくと,その要素が省略されているときに,指定したデフォルト値がセットされます.デフォルト値が設定されている要素は Required な要素でも送信時の JSONデータで省略できるので扱いが簡単になります.Payload の要素が本当に null をとる必要があるとき以外で,デフォルト値が設定できるならば,なるべく Default を設定して Required にしておくといいでしょう.

定義した Payload は ResourceAction 関数に設定して利用します.

var _ = Resource("bottle", func() {
        BasePath("/bottles")              
        DefaultMedia(BottleMedia)   

        Action("show", func() {          
                Description("Get bottle by id")
                Routing(GET("/:bottleID"))       
                Params(func() {                    
                        Param("bottleID", Integer, "Bottle ID")
                })
                Payload(BottlePayload) // ← ここに設定できる!
                Response(OK)                       
                Response(NotFound)            
        })
})

Payload は JSON である前提で話を進め来ましたが,実は,goa はデフォルトでは JSON でも XML でも Payload を受け付けます. たぶんしないと思うけど,Payload の形式を JSON のみに変更したりしたい場合は API 関数の Consumes 関数で設定することも出来ます :)

コラム:パラメータが必須要素であるときとそうでないときの扱いの違い

Payload のパラメータが必須である場合 (Required で指定されている場合)とそうでない場合で生成されるコードが変わってくるのですが,どのような違いがあるか知っておくと後々のためにも知っておいて損はありません.端的に言うと,Required されない要素は,生成されるコードでは,要素に対応する型の ポインタ型 で表現されます.

例えば,次のような Payload を考えます.

MyPayload = Type("MyPayload", func() {
        Member("reqired_member", String, "Required!")
        Member("optional_member", String, "Optional")
        Required("required_member")
})

上で定義している MyPayload の required_member は必須要素ですが,optional_member はそうではありません.このときこの Payload に対応して生成されるコードは次のようになります.

// MyPayload user type.
type MyPayload struct {
        RequiredMember string 
        OptionalMember *string
}

OptilalMember の方は,JSONnull を表現できるようにポインタになります.

同じようなことが MediaType の定義でも起きます.Payload は設定された値を読み出すだけなのでそんなに問題が起こらないのですが, MediaType はレスポンスデータなので,何かしらの値を設定してやる必要があります.そうすると,必須要素でない文字列型のパラメータがあったりすると, 文字列のポインタを値として設定したりしなければならなくなります.例としてちょっと上の MyPayload に値をセットしてみましょう.文字列のポインタは

var str := "hello"

p := MyPayload {
        RequiredMember = "aloha"
        OptionalMember = &str
}

としていったん変数に代入してから設定するか,

func toPtr(s string) *string {
    return &s
}

p := MyPayload {
        RequiredMember = "aloha"
        OptionalMember = toPtr("hello")
}

みたいに関数用意したりしないといけません.たいしたことではないんですが,やはり読み出すだけにしてもポインタは扱いが面倒なので,出来るだけ Required にしておいて,省略したいときはまずデフォルト値で対応できるか検討して,それでもやはり null で表現する必要がある要素であれば Required から外す,というのが個人的なオススメです.特に MediaTypeAttribute を指定するときには思い出して下さい.