こんにちは、サーバーサイドエンジニアの佐々木です。
先日、株式会社シーエー・モバイルさんから発表されたこちらの記事は皆さん読まれましたでしょうか?
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.js
のssl: true
をssl: 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に触って管理画面を追加してみるところまでの難易度としてはセキュリティ部分を考慮していくのが壁になっていそうかなと思いましたので、今回はセキュリティの考慮をしないような構成にしております。ご理解いただければ幸いです。
長い記事になりましたが、ここまでご覧いただきありがとうございました!