Vironとgoaをセキュリティのことは一旦忘れて試してみた

こんにちは、サーバーサイドエンジニアの佐々木です。

先日、株式会社シーエー・モバイルさんから発表されたこちらの記事は皆さん読まれましたでしょうか?
http://tech.camobile.com/entry/viron_20180201

弊社ではGoaを利用しているプロジェクトはSwaggerドキュメントを生成して社内連携していますので、これはもしかしたら親和性が高いのでは・・・!?と思い、使い始めの部分まで試してみたのですが、セキュリティ部分で初心者の方はつまづいてしまうのではないかと思いましたので、今回、セキュリティを使わずにサンプルとして簡単な画面表示をするまでの記事を書いていきます。

記事中のサンプルは以下のリポジトリにもコミットしてあります。
https://github.com/ginshari/sample-goa-viron

また、Exampleについてはシーエー・モバイルさんがすでに公式でgoaを利用しているものを用意してくださっているので、そちらもご覧ください。
https://github.com/cam-inc/viron/tree/example-go/example-go

また、yudpppさんのブログでも、サンプルコードを公開されていらっしゃいます。併せてご覧いただければと思います。
http://blog.yudppp.com/posts/viron_and_goa

※ この記事中で解説している設定及びコードには、執筆時点のVironの状態で書かれているため、最新状態のものには適用できないものもあるかもしれません、ご了承ください。

HTTPで接続可能にする

まず、VironはデフォルトでHTTPSの通信を行うように設定してくださっていますので、これをHTTP通信に変更します。
個人的にrollup.jsの仕組みをわかっていなかったので手こずったのですが、
rollup.local.config.jsssl: truessl: falseに変更することでできました。

import ObjectAssign from 'object-assign';
import server from 'rollup-plugin-server';
import baseConfig from './rollup.base.config.js';


const config = ObjectAssign({
  watch: {
    chokidar: true,
    include: 'src/**',
    exclude: 'src/css/**'
  }
}, baseConfig);

config.plugins.push(server({
  contentBase: 'dist', // Folder to serve files from,
  historyApiFallback: false, // Set to true to return index.html instead of 404
  host: 'localhost', // Options used in setting up server
  ssl: false,
  port: 8080
}));

export default config;

設定したら、利用するのは基本的にViron本体とDBだけでdemoのアプリケーションは必要ないので、以下のコマンドで起動します。

docker-compose -f docker-compose.dev.yml up --build web mysql

goaに自作APIを読み込ませる

サーバーの準備ができたところで、自作APIを読み込ませる準備をしていきましょう

goaに必須APIのdesignを追加する

まずはVironにAPIを読み込ませるための必須APIを追加していきます。
必須APIの仕様についてはドキュメントをご覧下さい。

以下の内容でdesign/api.goを作成します

package design

import (
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("My API", func() { // "My API" is the name of the API used in docs
    Title("Documentation title")      // Documentation title
    Description("The next big thing") // Longer documentation description
    Host("localhost:5000")            // Host used by Swagger and clients
    Scheme("http")                    // HTTP scheme used by Swagger and clients
    // BasePath("/api")                  // Base path to all API endpoints
    Consumes("application/json") // Media types supported by the API
    Produces("application/json") // Media types generated by the API
})

// ここから Viron必須部分
var _ = Resource("VironAuthtype", func() {
    Action("Show", func() {
        Routing(GET("viron_authtype"))
        Description("add viron authtype")

        Response(OK, func() {
            Media(CollectionOf(AuthType))
        })
    })
})

var _ = Resource("VironMenu", func() {
    Action("Show", func() {
        Routing(GET("viron"))
        Description("add viron authtype")

        Response(OK, func() {
            Media(MenuType)
        })
    })
})

var _ = Resource("public", func() {

    Origin("*", func() { // CORS policy that applies to all actions and file servers
        Methods("GET") // of "public" resource
    })

    Files("/swagger.json", "swagger/swagger.json")

    Files("/js/*filepath", "public/js/") // Serve all files under the public/js directory
})

// NumberType media type of status
var NumberType = MediaType("vnd.application/number+json", func() {
    Attributes(func() {
        Required(
            "value",
        )
        Attribute("value", Integer)
    })
    View("default", func() {
        Attribute("value")
    })
})

// AuthType AuthType for viron
var AuthType = MediaType("vnd.application/viron_authtype+json", func() {
    Attributes(func() {
        Required(
            "type",
            "provider",
            "url",
            "method",
        )
        Attribute("type", String, "type name")
        Attribute("provider", String, "provider name")
        Attribute("url", String, "url")
        Attribute("method", String, "request method to submit this auth")
    })
    View("default", func() {
        Attribute("type")
        Attribute("provider")
        Attribute("url")
        Attribute("method")
    })
})

// MenuType menu information on /viron
var MenuType = MediaType("vnd.application/viron_menu+json", func() {
    Attributes(func() {
        Required(
            "name",
            "pages",
        )
        Attribute("name", String)
        Attribute("thumbnail", String)
        Attribute("tags", ArrayOf(String))
        Attribute("color", String, func() {
            Enum("purple", "blue", "green", "yellow", "red", "gray", "black", "white")
        })
        Attribute("theme", String, func() {
            Enum("standard", "midnight", "terminal")
        })
        Attribute("pages", ArrayOf(PageType))
        Attribute("sections", ArrayOf(SectionType))
    })
    View("default", func() {
        Attribute("name")
        Attribute("thumbnail")
        Attribute("tags")
        Attribute("color")
        Attribute("theme")
        Attribute("pages")
    })
})

// SectionType component for section in viron
var SectionType = MediaType("vnd.application/viron_section+json", func() {
    Attributes(func() {
        Required(
            "id",
            "label",
        )
        Attribute("id", String)
        Attribute("label", String)
    })
    View("default", func() {
        Attribute("id")
        Attribute("label")
    })
})

// PageType pagetype media
var PageType = MediaType("vnd.application/viron_page+json", func() {
    Attributes(func() {
        Required(
            "id",
            "name",
            "section",
            "components",
        )
        Attribute("id", String)
        Attribute("name", String)
        Attribute("section", String)
        Attribute("group", String)
        Attribute("components", ArrayOf(ComponentType))
    })
    View("default", func() {
        Attribute("section")
        Attribute("group")
        Attribute("id")
        Attribute("name")
        Attribute("components")
    })
})

// ComponentType media type for component in viron
var ComponentType = MediaType("vnd.application/viron_component+json", func() {
    Attributes(func() {
        Required(
            "name",
            "style",
            "api",
        )
        Attribute("name", String)
        Attribute("style", String, func() {
            Enum("number",
                "table",
                "graph-bar",
                "graph-scatterplot",
                "graph-line",
                "graph-horizontal-bar",
                "graph-stacked-bar",
                "graph-horizontal-stacked-bar",
                "graph-stacked-area",
            )
        })
        Attribute("unit", String)
        Attribute("actions", ArrayOf(String))
        Attribute("api", APIType)
        Attribute("pagination", Boolean)
        Attribute("primary", String)
        Attribute("table_labels", ArrayOf(String))
        Attribute("query", ArrayOf(QueryType))
        Attribute("auto_refresh_sec", Integer)
    })
    View("default", func() {
        Attribute("name")
        Attribute("style")
        Attribute("unit")
        Attribute("actions")
        Attribute("api")
        Attribute("pagination")
        Attribute("primary")
        Attribute("table_labels")
        Attribute("query")
        Attribute("auto_refresh_sec")
    })
})

// APIType media type for api in viron
var APIType = MediaType("vnd.application/viron_api+json", func() {
    Attributes(func() {
        Required(
            "method",
            "path",
        )
        Attribute("method", String)
        Attribute("path", String)
    })
    View("default", func() {
        Attribute("method")
        Attribute("path")
    })
})

// QueryType mediatype for query in viron
var QueryType = MediaType("vnd.application/viron_query+json", func() {
    Attributes(func() {
        Required(
            "key",
            "type",
        )
        Attribute("key", String)
        Attribute("type", String)
    })
    View("default", func() {
        Attribute("key")
        Attribute("type")
    })
})

goagenを実行する

goagen main -d github.com/ginshari/sample-goa-viron/design
goagen app -d github.com/ginshari/sample-goa-viron/design
goagen controller -d github.com/ginshari/sample-goa-viron/design --pkg controllers -o controllers
goagen swagger -d github.com/ginshari/sample-goa-viron/design

app, controller, swaggerがそれぞれ完成しましたか?

CORSを設定

参照するURLがvironとapiで異なるため、CORSを設定します。
goaでのCORS設定は通常designのファイルに記載してgoagenを実行するのですが、
https://github.com/deadcheat/goacors
を利用してmainで設定することにします。

package main

import (
    "github.com/deadcheat/goacors"
    "github.com/ginshari/sample-goa-viron/app"
    "github.com/ginshari/sample-goa-viron/controllers"
    "github.com/goadesign/goa"
    "github.com/goadesign/goa/middleware"
)

func main() {
    // Create service
    service := goa.New("My API")

    // Mount middleware
    service.Use(middleware.RequestID())
    service.Use(middleware.LogRequest(true))
    service.Use(middleware.ErrorHandler(service, true))
    service.Use(middleware.Recover())
    service.Use(goacors.WithConfig(service, &goacors.DefaultGoaCORSConfig))

    // Mount "VironAuthtype" controller
    c := controllers.NewVironAuthtypeController(service)
    app.MountVironAuthtypeController(service, c)
    // Mount "VironMenu" controller
    c2 := controllers.NewVironMenuController(service)
    app.MountVironMenuController(service, c2)
    // Mount "public" controller
    c3 := controllers.NewPublicController(service)
    app.MountPublicController(service, c3)

    // Start service
    if err := service.ListenAndServe(":5000"); err != nil {
        service.LogError("startup", "err", err)
    }
}

必須APIのviron_authtypeを実装する

今回はローカル開発のため認証は組み込まずに実装していきますが、authtypeについては不要な場合でもレスポンスを返せないといけないようなので、最小限のレスポンスを返却できるよう修正します

package controllers

import (
    "github.com/ginshari/sample-goa-viron/app"
    "github.com/goadesign/goa"
)

// VironAuthtypeController implements the VironAuthtype resource.
type VironAuthtypeController struct {
    *goa.Controller
}

// NewVironAuthtypeController creates a VironAuthtype controller.
func NewVironAuthtypeController(service *goa.Service) *VironAuthtypeController {
    return &VironAuthtypeController{Controller: service.NewController("VironAuthtypeController")}
}

// Show runs the Show action.
func (c *VironAuthtypeController) Show(ctx *app.ShowVironAuthtypeContext) error {
    // VironAuthtypeController_Show: start_implement

    // Put your logic here
    a := &app.VironAuthtype{
        Type:     "signout",
        Provider: "",
        URL:      "",
        Method:   "GET",
    }
    res := make(app.VironAuthtypeCollection, 0)
    res = append(res, a)
    return ctx.OK(res)
    // VironAuthtypeController_Show: end_implement
}

一度読み込ませてみる

必須APIが弾かれずに動くか試してみましょう。

http://localhost:8080/を開きましょう

httpにする設定がうまく行っていれば、画面が開くはずです

「ホーム」の「+」ボタンをクリックして、

URLの入力欄にhttp://localhost:5000/swagger.jsonを入力し、「追加」をクリックします。

必須APIに問題が無ければ、ホームに追加されるはずです。
クリックするとエラーが出ますが、まだ管理メニューなどを追加していないので問題ないはずです。

APIにリソースを追加する

それでは、せっかくですので
– 何か数値が表示される画面
– ユーザー管理っぽい画面

を追加してみたいと思います。

メニューを追加する

vironはAPIの/vironに管理画面のメニューを取得しに行くようですので、これを追加します。
controllers/viron_menu.goがすでにgoagenで生成されていますのでこれを修正します。

package controllers

import (
    "github.com/ginshari/sample-goa-viron/app"
    "github.com/goadesign/goa"
)

// VironMenuController implements the VironMenu resource.
type VironMenuController struct {
    *goa.Controller
}

// NewVironMenuController creates a VironMenu controller.
func NewVironMenuController(service *goa.Service) *VironMenuController {
    return &VironMenuController{Controller: service.NewController("VironMenuController")}
}

// Show runs the Show action.
func (c *VironMenuController) Show(ctx *app.ShowVironMenuContext) error {
    // VironMenuController_Show: start_implement

    // Put your logic here
    t := "standard"
    cl := "green"
    tn := "https://avatars3.githubusercontent.com/u/2741873?s=200&v=4"

    pk := "id"
    pagination := false
    res := &app.VironMenu{
        Theme: &t,
        Color: &cl,
        Name:  "Sample-Goa-Viron - local",
        Tags: []string{
            "local",
        },
        Thumbnail: &tn,

        Pages: []*app.VironPage{
            &app.VironPage{
                Section: "dashboard",
                ID:      "sample-view",
                Name:    "サンプル表示",
                Components: []*app.VironComponent{
                    &app.VironComponent{
                        API: &app.VironAPI{
                            Method: "get",
                            Path:   "/status/available",
                        },
                        Name:  "Available",
                        Style: "number",
                    },
                },
            },
            &app.VironPage{
                Section: "manage",
                ID:      "user-admin",
                Name:    "ユーザー管理",
                Components: []*app.VironComponent{
                    &app.VironComponent{
                        API: &app.VironAPI{
                            Method: "get",
                            Path:   "/users",
                        },
                        Name:       "ユーザー一覧",
                        Style:      "table",
                        Primary:    &pk,
                        Pagination: &pagination,
                        Query: []*app.VironQuery{
                            &app.VironQuery{
                                Key:  "id",
                                Type: "integer",
                            },
                            &app.VironQuery{
                                Key:  "name",
                                Type: "string",
                            },
                            &app.VironQuery{
                                Key:  "from",
                                Type: "string",
                            },
                        },
                        TableLabels: []string{
                            "id",
                            "name",
                            "from",
                        },
                    },
                },
            },
        },
    }
    return ctx.OK(res)
    // VironMenuController_Show: end_implement
}

今回は

  • /status/available に問い合わせて、何か数値を表示する
  • /users でユーザーリソースを管理する

というメニュー構成にしました

designを追加する

goaのdesignに追加するAPIを定義しましょう
以下の内容を追記します

// NumberComponentを表示するためのお試しAPI
var _ = Resource("Available", func() {
    Action("Show", func() {
        Routing(GET("/status/available"))
        Description("add viron authtype")

        Response(OK, func() {
            Media(NumberType)
        })
    })
})

// Tableコンポーネントを使って管理画面を表示させるためのAPI
var _ = Resource("User", func() {
    Action("Show", func() {
        Routing(GET("/users"))
        Description("ユーザーを表示する")
        Response(OK, func() {
            Media(CollectionOf(UserType))
        })
        Response(NotFound)
    })
    Action("Create", func() {
        Routing(POST("/users"))
        Description("add user")
        Payload(func() {
            Member("name", String)
            Member("from", String)
            Required("name")
        })
        Response(Created, func() {
            Media(UserType)
        })
        Response(BadRequest)
    })
    Action("Update", func() {
        Routing(PUT("/users/:id"))
        Description("update user")
        Params(func() {
            Param("id", Integer, "user id")
        })
        Payload(func() {
            Member("name", String)
            Member("from", String)
            Required("name")
        })
        Response(NotFound)
        Response(OK, func() {
            Media(UserType)
        })
        Response(BadRequest)
    })

    Action("Delete", func() {
        Routing(DELETE("/users/:id"))
        Description("delete user")
        Params(func() {
            Param("id", Integer, "user id")
        })
        Response(OK)
        Response(NotFound)
    })
})

var UserType = MediaType("vnd.application/user+json", func() {
    Attributes(func() {
        Required(
            "id",
            "name",
            "from",
            "createdAt",
            "updatedAt",
        )
        Attribute("id", Integer)
        Attribute("name", String)
        Attribute("from", String)
        Attribute("createdAt", DateTime)
        Attribute("updatedAt", DateTime)
    })
    View("default", func() {
        Attribute("id")
        Attribute("name")
        Attribute("from")
        Attribute("createdAt")
        Attribute("updatedAt")
    })
})

数値のコンポーネントに表示させるAPIについては、エレメント名をvalueとするようにしてください。
こうしないと、viron側がパースに失敗してしまうようで、エラーが表示されてしまいます。

designファイルを記述したら、goagenし直しておきましょう。main.goでコントローラをバインドするのも忘れずに。

APIを実装する

それではそれぞれのAPIを実装しましょう

  • /status/available
package controllers

import (
    "github.com/ginshari/sample-goa-viron/app"
    "github.com/goadesign/goa"
)

// AvailableController implements the Available resource.
type AvailableController struct {
    *goa.Controller
}

// NewAvailableController creates a Available controller.
func NewAvailableController(service *goa.Service) *AvailableController {
    return &AvailableController{Controller: service.NewController("AvailableController")}
}

// Show runs the Show action.
func (c *AvailableController) Show(ctx *app.ShowAvailableContext) error {
    // AvailableController_Show: start_implement

    // Put your logic here

    res := &app.Number{
        Value: 1,
    }
    return ctx.OK(res)
    // AvailableController_Show: end_implement
}

お試しなのでとりあえず1を返すだけのAPIにします

  • /users
package controllers

import (
    "database/sql"
    "fmt"
    "time"

    "github.com/ginshari/sample-goa-viron/app"
    _ "github.com/go-sql-driver/mysql"
    "github.com/goadesign/goa"
)

// UserController implements the User resource.
type UserController struct {
    db *sql.DB
    *goa.Controller
}

// NewUserController creates a User controller.
func NewUserController(service *goa.Service) *UserController {
    c := &UserController{Controller: service.NewController("UserController")}
    var err error
    c.db, err = sql.Open("mysql", "root:password@tcp(localhost:3306)/useradmin?parseTime=true&loc=Asia%2FTokyo")
    if err != nil {
        return nil
    }
    return c
}

// Create runs the Create action.
func (c *UserController) Create(ctx *app.CreateUserContext) error {
    // UserController_Create: start_implement

    // Put your logic here
    // とりあえずめっちゃ雑に
    db := c.db
    ps, err := db.Prepare("INSERT INTO user(name, `from`) VALUES (?, ?)")
    if err != nil {
        fmt.Println(err)
        return ctx.BadRequest()
    }
    res, err := ps.Exec(ctx.Payload.Name, ctx.Payload.From)
    if err != nil {
        fmt.Println(err)
        return ctx.BadRequest()
    }
    id, _ := res.LastInsertId()
    user := app.User{
        ID:   int(id),
        Name: ctx.Payload.Name,
        From: *ctx.Payload.From,
    }
    return ctx.Created(&user)
    // UserController_Create: end_implement
}

// Delete runs the Delete action.
func (c *UserController) Delete(ctx *app.DeleteUserContext) error {
    // UserController_Delete: start_implement

    // Put your logic here
    db := c.db
    _, err := db.Exec("DELETE FROM user WHERE id = ?", ctx.ID)
    if err != nil {
        fmt.Println(err)
        return ctx.NotFound()
    }

    return ctx.OK(nil)
    // UserController_Delete: end_implement
}

// Show runs the Show action.
func (c *UserController) Show(ctx *app.ShowUserContext) error {
    // UserController_Show: start_implement

    // Put your logic here
    r, err := c.db.Query("SELECT u.id, u.name, u.from, u.created_at, u.updated_at from user u")
    if err != nil {
        return err
    }
    res := make(app.UserCollection, 0)
    for r.Next() {
        var id int
        var name, from string
        var ca, ua time.Time
        if err := r.Scan(&id, &name, &from, &ca, &ua); err != nil {
            return err
        }
        u := app.User{
            ID:        id,
            Name:      name,
            From:      from,
            CreatedAt: ca,
            UpdatedAt: ua,
        }
        res = append(res, &u)
    }
    return ctx.OK(res)
    // UserController_Show: end_implement
}

// Update runs the Update action.
func (c *UserController) Update(ctx *app.UpdateUserContext) error {
    // UserController_Update: start_implement

    // Put your logic here
    _, err := c.db.Exec("UPDATE user SET name = ?, `from` = ? WHERE id = ?", ctx.Payload.Name, ctx.Payload.From, ctx.ID)
    if err != nil {
        fmt.Println(err)
        return err
    }
    res := &app.User{
        ID:   ctx.ID,
        Name: ctx.Payload.Name,
        From: *ctx.Payload.From,
    }
    return ctx.OK(res)
    // UserController_Update: end_implement
}

mysqlの接続先は適宜mysqlが動いている先に修正してください。
なお、今回のサンプルでは、DDLは以下のようにしました

create table user
(
    id int auto_increment
        primary key,
    name varchar(128) not null,
    `from` varchar(1000) default '未回答' not null comment '出身地',
    created_at datetime default CURRENT_TIMESTAMP not null,
    updated_at datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP
)
engine=InnoDB
;

ちなみに、vironが参照しているmysqlで動かす、という場合は、sqlファイルを保存した上で
docker-composer上でSQLを保存したディレクトリを/docker-entrypoint-initdb.d にボリューム割当すると自動で実行してくれます。

いざ実行

実装できたら実行してみましょう
vironのホームから先程追加したAPIのカードをクリックしてみます。

数値項目が表示されましたか?されてなかったらすみません!

ユーザー管理画面に遷移してみましょう

追加前なので0件ですね、+をクリックして追加画面を表示します。

項目を入力します

入力チェックなどもswaggerから適用してくれているようです

作成するボタンをクリック
確認ダイアログが表示されるのでOKします(「新規作成する」ボタンをクリック)

編集、削除についても試してみましょう。うまくいきましたか?

最後に

いかがでしたでしょうか?
私は今回簡単に触ってみたことで、Vironが必須APIや、メニューへの理解など、少し学習コストはもちろんあるものの、覚えてしまえば管理画面の作成コストを軽減するのに役立つツールだということは確かなように感じました。シーエー・モバイルさんありがとうございます。
セキュリティ面については全く触れない記事となっておりますが、初めてVironに触って管理画面を追加してみるところまでの難易度としてはセキュリティ部分を考慮していくのが壁になっていそうかなと思いましたので、今回はセキュリティの考慮をしないような構成にしております。ご理解いただければ幸いです。

長い記事になりましたが、ここまでご覧いただきありがとうございました!