GAE/Go で API サーバーと管理画面を同一アプリケーションで動かす

# はじめに

* GAE/Go で API サーバーを動かすにあたって管理画面を同一アプリケーション上で動かす方法について考えてみた
* 管理画面なので認証機能はほしい
  * GAE のライブラリ `google.golang.org/appengine/user` を使えば Google アカウントで認証できる
  * 同ライブラリでは Google アカウントの権限によってアクセスレベルを設定できる
  * 権限は Google Cloud Platform のコンソールで IAM と管理の項目からコントロールできる
    * 権限の種別は管理者かそうでないかだけ
    * IAM 上で App Engine のオーナーや管理者などの何らかの権限を与えると管理者扱いになる様子
* 管理画面のコンテンツの例として Toml ファイルを読み込んで Datastore にインポートする画面を作ってみる
  * GAE/Go の開発環境では Datastore の（エミュレータの）管理画面が貧弱でデータの追加が出来ないので地味に面倒

# サンプルアプリのリポジトリ

* https://github.com/nirasan/gae-admin

# 使い方

* 管理画面へは `/admin/` でアクセスする
* 管理画面のトップからログインする
  * 開発環境だと任意のEメールアドレスで任意の権限でログインをして動作を確認できる
  * 本番環境だとログイン中の Google アカウントでアプリケーションにアクセスするかどうかの確認画面が表示される

# アプリの設定

* `/admin` 配下を管理画面に `/api` 配下を API サーバーとする
* `/admin/` にはログイン用のリンクを置くので誰でもアクセスできる
* `/admin/.*` へのアクセスには `login: admin` によって管理者権限が必要になる

```yaml:app.yaml
runtime: go
api_version: go1

handlers:
- url: /admin/
  script: _go_app
- url: /admin/.*
  script: _go_app
  login: admin
- url: /api/.*
  script: _go_app
```

# アプリの実装

```go:app.go
package app

import (
	"encoding/json"
	"github.com/gorilla/mux"
	"github.com/naoina/toml"
	"google.golang.org/appengine"
	"google.golang.org/appengine/datastore"
	"google.golang.org/appengine/user"
	"io/ioutil"
	"net/http"
	"text/template"
)

func init() {
	r := mux.NewRouter()

	// Management page
	admin := r.PathPrefix("/admin").Subrouter()
	admin.Path("/").HandlerFunc(adminIndexHandler)
	admin.Path("/toml").HandlerFunc(adminTomlImportHander)

	// API page
	api := r.PathPrefix("/api").Subrouter()
	api.Path("/").HandlerFunc(apiIndexHandler)

	http.Handle("/", r)
}

// Top of management pages
func adminIndexHandler(w http.ResponseWriter, r *http.Request) {

	ctx := appengine.NewContext(r)
	u := user.Current(ctx)

	tmpl := `
<!DOCTYPE html>
<html>
	<head>
		<title>Admin index page</title>
	</head>
	<body>
		{{ if .User }}
			Welcome, {{ .User }} (<a href="{{ .LogoutUrl }}">sign out</a>)
			<h3>contents</h3>
			<ul>
			  <li><a href="/admin/toml">Toml Importer</a></li>
			</ul>
		{{ else }}
			<a href="{{ .LoginUrl }}">Sign in or register</a><br>
		{{ end }}
	</body>
</html>
`
	t, err := template.New("").Parse(tmpl)
	if err != nil {
		panic(err)
	}

	loginUrl, _ := user.LoginURL(ctx, "/admin/")
	logoutUrl, _ := user.LogoutURL(ctx, "/admin/")

	data := struct {
		User      *user.User
		LoginUrl  string
		LogoutUrl string
	}{
		User:      u,
		LoginUrl:  loginUrl,
		LogoutUrl: logoutUrl,
	}

	err = t.Execute(w, data)
	if err != nil {
		panic(err)
	}
}

// Toml file importer
func adminTomlImportHander(w http.ResponseWriter, r *http.Request) {

	ctx := appengine.NewContext(r)

	if r.Method == "POST" {

		// read uploaded file
		f, _, err := r.FormFile("uploadfile")
		if err != nil {
			panic(err)
		}
		buf, err := ioutil.ReadAll(f)
		if err != nil {
			panic(err)
		}

		// struct of datadtore kind
		type Table1 struct {
			Id   int64
			Col1 string
			Col2 int64
		}

		// struct of toml file
		var input struct {
			Tables []Table1
		}

		// parse toml
		if err = toml.Unmarshal(buf, &input); err != nil {
			panic(err)
		}

		// import toml data
		for _, table := range input.Tables {
			key := datastore.NewKey(ctx, "Table1", "", table.Id, nil)
			if _, err := datastore.Put(ctx, key, &table); err != nil {
				panic(err)
			}
		}
	}

	// render form
	tmpl := `
<!DOCTYPE html>
<html>
	<head>
		<title>Admin toml importer</title>
	</head>
	<body>
		<form enctype="multipart/form-data" action="/admin/toml" method="post">
			<input type="file" name="uploadfile" />
			<input type="submit" value="upload" />
		</form>
	</body>
</html>
`
	t, err := template.New("").Parse(tmpl)
	if err != nil {
		panic(err)
	}

	t.Execute(w, nil)
}

// Api page (dummy)
func apiIndexHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	data := struct{ Message string }{Message: "hello world"}
	json.NewEncoder(w).Encode(data)
}
```

# インポートするファイル

```toml:table1.toml
[[Tables]]
Id = 1
Col1 = "string1"
Col2 = 100

[[Tables]]
Id = 2
Col1 = "string2"
Col2 = 200

[[Tables]]
Id = 3
Col1 = "string3"
Col2 = 300
```