My Note

自己理解のためのブログ

Echo ( GoWebフレームワーク ) on GAE で仮想通貨の価格を返すAPIを作成した

はじめに

今回は自分のAPIサーバで仮想通貨の現在価格情報を返す機能が欲しかったので取引所のAPIを使って実装しました。

データの取得先

価格を取得するために、GMOコインとCoincheck を利用しました。

APIについて

  • リクエストについて

    • ${ENDPOINT}/crypto/${exchage}\?symbol=${トークン名}
      • クエリパラメータに トークン名 { BTC, ETH等々.. }
  • レスポンスについて

■ レスポンス結果(例)

{
  "data": [
    {
      "symbol": "BTC_JPY",
      "last": "4554436",
      "timestamp": "2022-03-11T07:22:19.507Z"
    }
  ]
}
{
  "symbol": "btc_jpy",
  "last": 4518231,
  "time": "2022/02/21-21:25:42"
}

ソースコード

├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── investment_trust.go
│   ├── metal.go
│   └── staticcheck.conf
├── app.yaml
├── authentication
│   └── basic_auth.go
├── go.mod
├── go.sum
├── main.go
└── secret.yaml

■ main.go

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "google.golang.org/appengine"

    "github.com/yhidetoshi/apiEchoGAE-private/api"
    "github.com/yhidetoshi/apiEchoGAE-private/authentication"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())
    e.Use(authentication.BasicAuth()) // Basic Auth

    cGmo := api.ClientCryptoGMO{}
    cGmo.NewClientCryptoGMO()
    cCc := api.ClientCryptoCoincheck{}
    cCc.NewClientCryptoCoincheck()
    // ルーティング //

    // ヘルスチェック
    e.GET("/api/healthcheck", api.Healthcheck)

    // 貴金属の価格を取得
    e.GET("/api/metal", api.FetchMetal)

    // ISINコードを引数に基準価格を取得
    // curl -sS localhost:1323/investment-trust/JP90C000GKC6
    e.GET("/api/investment-trust/:isin", api.FetchInvestTrust)

    // 仮想通貨の価格を取得
    e.GET("/api/crypto/gmo", cGmo.FetchCryptoGMO)
    e.GET("/api/crypto/coincheck", cCc.FetchCryptoCoincheck)

    // LocalとGAEの切り替え
    appengine.Main() // GAEの場合に有効化
    //e.Start(":1323") // Localの場合に有効化
}

■ client.go

package api

var (
    defaultURLCryptoGMO       = "https://api.coin.z.com/public/v1/ticker?symbol="
    defaultURLCryptoCoincheck = "https://coincheck.com/api/ticker?symbol="
)

type ClientCryptoCoincheck struct {
    URL *string
}
type ClientCryptoGMO struct {
    URL *string
}

func (c *ClientCryptoGMO) NewClientCryptoGMO() {
    c.URL = &defaultURLCryptoGMO
}

func (c *ClientCryptoCoincheck) NewClientCryptoCoincheck() {
    c.URL = &defaultURLCryptoCoincheck
}

■ crypto_gmo.go

package api

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
)

type CryptoInfoGMO struct {
    DataGMO []DataGMO `json:"data"`
}

type DataGMO struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"` // 最後の取引価格
    Timestamp string `json:"timestamp"`
}

type ResponseCryptoGMO struct {
    ResponseDataGMO []ResponseDataGMO `json:"data"`
}

type ResponseDataGMO struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"`
    Timestamp string `json:"timestamp"`
}

func (cl *ClientCryptoGMO) FetchCryptoGMO(c echo.Context) error {
    var url string
    cig := &CryptoInfoGMO{}
    symbol := c.QueryParam("symbol") // BTC, ETH..

    // URLを場合別け(テストと外部API)
    if *cl.URL == defaultURLCryptoGMO {
        url = *cl.URL + symbol
    } else {
        url = *cl.URL
    }

    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        log.Println(err)
    }

    client := new(http.Client)
    resp, err := client.Do(req)
    if err != nil {
        log.Println(err)
    }
    defer resp.Body.Close()

    err = json.NewDecoder(resp.Body).Decode(cig)
    if err != nil {
        log.Println(err)
    }
    responseJSON := []ResponseCryptoGMO{
        {
            ResponseDataGMO: []ResponseDataGMO{
                {
                    Symbol:    cig.DataGMO[0].Symbol,
                    Last:      cig.DataGMO[0].Last,
                    Timestamp: cig.DataGMO[0].Timestamp,
                },
            },
        },
    }
    response, err := json.Marshal(responseJSON[0])
    fmt.Printf("response=%s\n", string(response))

    if err != nil {
        log.Println(err)
    }

    return c.String(http.StatusOK, string(response))
}

■ crypto_coincheck.go

package api

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
)

type CryptoInfoCoincheck struct {
    Last      interface{} `json:"last"`
    Timestamp int64       `json:"timestamp"`
}

type ResponseCryptoCoincheck struct {
    Symbol    string `json:"symbol"`
    Last      string `json:"last"`
    Timestamp int64  `json:"timestamp"`
}

func (cl *ClientCryptoCoincheck) FetchCryptoCoincheck(c echo.Context) error {
    var url, last string
    coincheck := &CryptoInfoCoincheck{}
    symbol := c.QueryParam("symbol") // BTC, ETH..

    // URLを場合別け(テストと外部API)
    if *cl.URL == defaultURLCryptoCoincheck {
        url = *cl.URL + symbol
    } else {
        url = *cl.URL + "/" + symbol
    }
    fmt.Printf("url=%s\n", url)

    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        log.Println(err)
    }

    client := new(http.Client)
    resp, err := client.Do(req)
    if err != nil {
        log.Println(err)
    }
    defer resp.Body.Close()

    err = json.NewDecoder(resp.Body).Decode(coincheck)
    if err != nil {
        log.Println(err)
    }
    // check type for interface{} value of last
    switch coincheck.Last.(type) {
    case float64:
        last = fmt.Sprintf("%.10g", coincheck.Last) // no use 'exponential(e+)'
    case string:
        last = coincheck.Last.(string)
    }
    responseJSON := ResponseCryptoCoincheck{
        Symbol:    symbol,
        Last:      last,
        Timestamp: coincheck.Timestamp,
    }
    response, err := json.Marshal(responseJSON)

    //fmt.Println(string(response))
    if err != nil {
        log.Println(err)
    }

    return c.String(http.StatusOK, string(response))
}

■ 確認

- GMOコインの場合
curl -sSu ${ID}:${PW} ${ENDPOINT}/api/crypto/gmo\?symbol=BTC | jq 
{
  "data": [
    {
      "symbol": "BTC_JPY",
      "last": "4592081",
      "timestamp": "2022-03-13T05:56:32.907Z"
    }
  ]
}

- Coincheckの場合
curl -sSu ${ID}:${PW} ${ENDPOINT}/api/crypto/coincheck\?symbol=btc_jpy | jq
{
  "symbol": "btc_jpy",
  "last": "4592591",
  "timestamp": 1647151043
}

デプロイについて

今回のGoアプリはGoogle App Engineにデプロイしているので、Github Actionsで自動デプロイするようにしています。 そちらについては以前に記事でまとめています。

yhidetoshi.hatenablog.com

yhidetoshi.hatenablog.com

Google App Script(GAS)で スクレイピングとAPIコール で情報を取得 & スプレッドシートのバックアップ を定期実行する

はじめに

Google App Scriptを確認あたってメモしておいた方がいいと思った部分について記載します。

主な内容は以下の通り。

スクレイピングする

"App Script" の画面で 「ライブラリ」を追加を押して 以下のスクリプトIDを入力して追加する。追加したら "Parser" が使えるようになります。

スクリプトID:1Mc8BthYthXx6CoIz90-JiSzSafVnT6U3t0z_W3hLTAX5ek4w0G_EIrNw

f:id:yhidetoshi:20220228150155p:plain

サイトに直接スクレイピングする場合

スクレイピングしているコードの一部抜粋

// kakaku.comが公開しているサイトへスクレイピング
function fetchInvestmentTrustPriceFromKakaku(isinCode) {
    let url = "https://kakaku.com/fund/detail.asp?si_isin=" + isinCode;
    let html = UrlFetchApp.fetch(url).getContentText();
    // Parser.data(‘抽出データ').from(‘開始文字列').to(‘終了文字列').build()
    let basePrice = Parser.data(html).from('<span class="price">').to('</span>').build();
    return basePrice
}
  • 以下のコードは金価格を取得するコードです。
// MyGoldPartnerから金価格をスクレイピング
function fetchGoldPrice() {
    const url = "https://gold.mmc.co.jp/market/gold-price/"
    let html = UrlFetchApp.fetch(url).getContentText();
    let goldPrices = Parser.data(html).from('<span class="c-table__text--xl">').to('</span>').iterate();
    return [goldPrices[4], goldPrices[5]];
}

GAEで実装したAPIをコールする場合

GAEで実装しているAPIJSONで返すので jsonをパースする処理にしています。

/* レスポンス例
{ "time": "2022-02-16T21:44:10.569731882Z", "base_price": 10001, "day_before_price": 0 }
*/
function fetchInvestmentTrustPriceFromGAE(gaeEndpoint, requestPath, isinCode, urlOptions) {
    let url = gaeEndpoint + requestPath + isinCode
    let res = UrlFetchApp.fetch(url, urlOptions);
    let resJson = JSON.parse(res.getContentText());

    return resJson;
}

// 自前のGoogle App Engine (GAE) 金価格を取得
/* レスポンス例
{ "time": "2022-02-17T21:04:11.755729+09:00", "gold": 7656, "platinum": 4373 }
*/
function fetchGoldPriceFromGAE(gaeEndpoint, requestPath, urlOptions) {
    let url = gaeEndpoint + requestPath
    let res = UrlFetchApp.fetch(url, urlOptions);
    let resJson = JSON.parse(res.getContentText());
    //let goldPrice = resJson['gold']

    return resJson;
}

GASに機密情報を登録する

以前は管理画面にて登録できたようですが、今はコードから登録する必要があったので以下のコードから登録しました。 今回登録したのは、GAEをベーシック認証をかけているので "ENDPOINT" と "ID" と "PW" を登録しました。

  • プロパティに登録するコード
function setProperty() {
    let prop = PropertiesService.getScriptProperties();
    /* ex
    let secrets = {
        "hoge": "abc",
        "fuga": "123"
    }
    */
    let secrets = {
        "ENDPOINT": "GAEのエンドポイントを設定",
        "ID": "xxx",
        "pass": "yyy"
    }
    prop.setProperties(secrets)
}
  • 登録したプロパティから値を参照するコード(例)
const gaeEndpoint = PropertiesService.getScriptProperties().getProperty("ENDPOINT") // GAEのエンドポイン
  • Basic認証をするために ( urlOptions として認証情報を渡す )
const gaeEndpoint = PropertiesService.getScriptProperties().getProperty("ENDPOINT") // GAEのエンドポイン
const basicAuthId = PropertiesService.getScriptProperties().getProperty("ID")       // Basic認証のID(セットプロパティから取得)
const basicAuthPass = PropertiesService.getScriptProperties().getProperty("PASS")   // Basic認証のPW(セットプロパティから取得)

// Basic認証
  let urlOptions = {
      method: "GET",
      headers: { "Authorization": "Basic " + Utilities.base64Encode(basicAuthId + ":" + basicAuthPass) }
  }

function fetchInvestmentTrustPriceFromGAE(gaeEndpoint, requestPath, isinCode, urlOptions) {
// 省略

シートを別のスプレッドシートにバックアップするコード

function execBackup() {
    // 保存元のシートID
    const originalSheetId = 'オリジナルシートID';
    // 保存先のシートID
    const backupSheetId = '保存先のシートID;
    // 保存元データのシート名(保存先データのシート名と同一)
    const sheets = ['sheet_A', 'sheet_B', 'sheet_C'] // 対象のシート名をセット

    // シート毎に処理
    for (let i = 0; i < sheets.length; i++) {
        // オリジナルデータの設定
        let originalSheet = SpreadsheetApp.openById(originalSheetId);
        let originalSheetName = originalSheet.getSheetByName(sheets[i]);
        // バックアップ先の設定
        let backupSheet = SpreadsheetApp.openById(backupSheetId);
        let backupSheetName = backupSheet.getSheetByName(sheets[i]);

        // 保存先のデータを削除
        backupSheetName.clear();

        let lastRow = originalSheetName.getLastRow();
        let lastColumn = originalSheetName.getLastColumn();
        let copyData = originalSheetName.getRange(1, 1, lastRow, lastColumn).getValues();

        // データの書き込み
        backupSheetName.getRange(1, 1, lastRow, lastColumn).setValues(copyData);
    }
}

GASのスクリプトを定期実行する

function execBackup() {} を作成したのでバックアップを実行するときは execBackup を選択。

  1. トリガーを選択
  2. トリガーを追加
  3. 実行する関数名をプルダウンメニューから選択

f:id:yhidetoshi:20220228153827p:plain

Echo ( GoのWebフレームワーク ) on GAE で貴金属 ( 金 & プラチナ )の価格を返すAPIを作成した

はじめに

以前に 貴金属の価格を取得して可視化する内容で記事を作成しました。 今回は Echo( Goフレームワーク ) でデータを取得する部分についてアップデートした部分についてです。 金融資産のポートフォリオを可視化するために必要な情報を取得するためにデータ取得先、取得データ等を変更し、Echoのバージョンをアップデートしました。

yhidetoshi.hatenablog.com

データの取得先

価格を取得するために、マイゴールドパートナー を利用しました。

スクレイピング

スクレイピングは対象サイトに迷惑がかからないようにする必要があります。 価格は基本的に日時で更新されるので1日1回定期アクセスするようにしており、APIを叩く際はベーシック認証をかけて 外部から不用意にアクセスされないように制御しています。

Goでスクレイピングするために、 goquery を利用しました。

github.com

APIについて

  • リクエストについて

    • ${ENDPOINT}/metal
      • リクエストパスは /metal と設定
  • レスポンスについて

    • jsonで以下の情報を返す
      • 金のweb買取価格
      • 金のweb前日比価格
      • プラチナのweb買取価格
      • プラチナのweb前日比価格
      • タイムスタンプ

■ レスポンス結果(例)

{
  "gold": 7697,
  "gold_day_before_price": 28,
  "platinum": 4301,
  "platinum_day_before_price": -71,
  "time": "2022/02/2-21:14:28"
}

ソースコード

参考までにディレクトリ構造は以下の通り

.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── investment_trust.go
│   ├── metal.go
│   └── staticcheck.conf
├── app.yaml
├── authentication
│   └── basic_auth.go
├── go.mod
├── go.sum
├── main.go
└── secret.yaml

■ main.go

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "google.golang.org/appengine"

    "github.com/yhidetoshi/apiEchoGAE-private/api"
    "github.com/yhidetoshi/apiEchoGAE-private/authentication"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())
    e.Use(authentication.BasicAuth()) // Basic Auth

    cGmo := api.ClientCryptoGMO{}
    cGmo.NewClientCryptoGMO()
    cCc := api.ClientCryptoCoincheck{}
    cCc.NewClientCryptoCoincheck()
    // ルーティング //

    // ヘルスチェック
    e.GET("/api/healthcheck", api.Healthcheck)

    // 貴金属の価格を取得
    e.GET("/api/metal", api.FetchMetal)

    // ISINコードを引数に基準価格を取得
    // curl -sS localhost:1323/investment-trust/JP90C000GKC6
    e.GET("/api/investment-trust/:isin", api.FetchInvestTrust)

    // 仮想通貨の価格を取得
    e.GET("/api/crypto/gmo", cGmo.FetchCryptoGMO)
    e.GET("/api/crypto/coincheck", cCc.FetchCryptoCoincheck)

    // LocalとGAEの切り替え
    appengine.Main() // GAEの場合に有効化
    //e.Start(":1323") // Localの場合に有効化
}

■ base.go

package handler

import (
    "log"
    "strconv"
    "time"
)

const (
    timezone   = "Asia/Tokyo"
    offset     = 9 * 60 * 60
    timeFormat = "2006/01/02-15:04:05"
)

var (
    jst     = time.FixedZone(timezone, offset)
    nowTime = time.Now().In(jst).Format(timeFormat)
)

func convertStringToInt(value string) int {
    intValue, err := strconv.Atoi(value)
    if err != nil {
        log.Println("Error", err)
    }
    return intValue
}

■ metal.go

package api

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "strings"

    "github.com/PuerkitoBio/goquery"
    "github.com/labstack/echo/v4"
)

/* Response
{
    "gold": 7697,
    "gold_day_before_price": 28,
    "platinum": 4301,
    "platinum_day_before_price": -71,
    "time": "2022/02/21-21:14:28"
}
*/

const (
    targetURLMetal              = "https://gold.mmc.co.jp/"
    goldPriceCount              = 8
    goldDayBeforePriceCount     = 9
    platinumPriceCount          = 48
    platinumDayBeforePriceCount = 49
)

type Metal struct {
    GoldPrice              int    `json:"gold"`
    GoldDayBeforePrice     int    `json:"gold_day_before_price"`
    PlatinumPrice          int    `json:"platinum"`
    PlatinumDayBeforePrice int    `json:"platinum_day_before_price"`
    Date                   string `json:"time"`
}

func FetchMetal(c echo.Context) error {
    var goldPrice, goldDayBeforePrice, platinumPrice, platinumDayBeforePrice string
    var tdCount int

    res, err := http.Get(targetURLMetal)
    if err != nil {
        log.Println(err)
        os.Exit(1)
    }
    defer res.Body.Close()

    doc, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        log.Println(err)
    }

    // Fetch gold and platinum price
    doc.Find("div.p-table-scroll--sticky > table > tbody > tr > td").Each(func(_ int, s *goquery.Selection) {
        if tdCount == goldPriceCount { //Gold Price
            goldPrice = s.Find("span.c-table__text--xl").Text()
        }
        if tdCount == goldDayBeforePriceCount { //Gold Price Day Before Price Count
            goldDayBeforePrice = s.Find("span.c-table__text--xl").Text()
        }
        if tdCount == platinumPriceCount { // Platinum Price
            platinumPrice = s.Find("span.c-table__text--xl").Text()
        }
        if tdCount == platinumDayBeforePriceCount { // Platinum Day Before Price Count
            platinumDayBeforePrice = s.Find("span.c-table__text--xl").Text()
        }
        tdCount++
    })
    // Format
    strGoldPrice := strings.Replace(goldPrice, ",", "", -1)
    strGoldDayBeforePrice := strings.Replace(goldDayBeforePrice, "+", "", -1)

    strPlatinumPrice := strings.Replace(platinumPrice, ",", "", -1)
    strPlatinumDayBeforePrice := strings.Replace(platinumDayBeforePrice, "+", "", -1)

    // Convert string to int
    intGoldPrice := convertStringToInt(strGoldPrice)
    intGoldDayBeforePrice := convertStringToInt(strGoldDayBeforePrice)
    intPlatinumPrice := convertStringToInt(strPlatinumPrice)
    intPlatinumDayBeforePrice := convertStringToInt(strPlatinumDayBeforePrice)

    // Set value to json
    responseJSON := Metal{
        GoldPrice:              intGoldPrice,
        GoldDayBeforePrice:     intGoldDayBeforePrice,
        PlatinumPrice:          intPlatinumPrice,
        PlatinumDayBeforePrice: intPlatinumDayBeforePrice,
        Date:                   nowTime,
    }

    response, err := json.Marshal(responseJSON)
    if err != nil {
        log.Println(err)
    }
    return c.String(http.StatusOK, string(response))
}

■ 確認

❯ curl -sSu ${ID}:${PW} ${ENDPOINT}/metal | jq 
{
  "gold": 7698,
  "gold_day_before_price": 1,
  "platinum": 4338,
  "platinum_day_before_price": 37,
  "time": "2022/02/22-21:19:14"
}

デプロイについて

今回のGoアプリはGoogle App Engineにデプロイしているので、Github Actionsで自動デプロイするようにしています。 そちらについては以前に記事でまとめています。

yhidetoshi.hatenablog.com

yhidetoshi.hatenablog.com

Echo ( GoのWebフレームワーク ) on GAE で投資信託の基準価格を返すAPIを作成した

はじめに

今回は金融資産のポートフォリオ可視化するために投資信託の情報を返してくれるAPIが欲しかったので 勉強も兼ねてEchoで作成してみました。

yhidetoshi.hatenablog.com

データの取得先とISINコード

価格を取得するために、価格コムのサイト を利用しました。 kakaku.com

投資信託には ISINコードが割り当てられています。 このサイトでは クエリパラメータに ISINコード を渡せばその投資信託の基準価格のサイトを表示できます。

スクレイピング

スクレイピングは対象サイトに迷惑がかからないようにする必要があります。 価格は基本的に日時で更新されるので1日1回定期アクセスするようにしており、APIを叩く際はベーシック認証をかけて 外部から不用意にアクセスされないように制御しています。

Goでスクレイピングするために、 goquery を利用しました。

github.com

APIについて

e.GET("/investment-trust/:isin", api.FetchInvestTrust)
  • レスポンスについて
    • jsonで以下の情報を返す
      • 基準価格
      • 前日比価格
      • タイムスタンプ

■ レスポンス結果(例)

{
  "base_price": 17569,
  "day_before_price": -461,
  "time": "2022/02/21-22:25:42"
}
<div class="infoMainTop clearfix">
<dl>
<dt>基準価額:</dt>
<dd><span class="price">
17,462

</span>円 
前日比
<span class="minus">-107</span>円(-0.61%)</dd>
</dl>

ソースコード

参考までにディレクトリ構造は以下の通り

.
├── README.md
├── api
│   ├── base.go
│   ├── client.go
│   ├── crypto_coincheck.go
│   ├── crypto_coincheck_test.go
│   ├── crypto_gmo.go
│   ├── crypto_gmo_test.go
│   ├── healthcheck.go
│   ├── investment_trust.go
│   ├── metal.go
│   └── staticcheck.conf
├── app.yaml
├── authentication
│   └── basic_auth.go
├── go.mod
├── go.sum
├── main.go
└── secret.yaml
  • Goのバージョン
❯ go version
go version go1.17.6 darwin/amd64

main.go

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "google.golang.org/appengine"

    "github.com/yhidetoshi/apiEchoGAE-private/api"
    "github.com/yhidetoshi/apiEchoGAE-private/authentication"
)

var e = createMux()

func createMux() *echo.Echo {
    e := echo.New()
    return e
}

func main() {
    // Echoインスタンス作成 //
    http.Handle("/", e)
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())
    e.Use(middleware.Gzip())
    e.Use(authentication.BasicAuth()) // Basic Auth

    cGmo := api.ClientCryptoGMO{}
    cGmo.NewClientCryptoGMO()
    cCc := api.ClientCryptoCoincheck{}
    cCc.NewClientCryptoCoincheck()
    // ルーティング //

    // ヘルスチェック
    e.GET("/api/healthcheck", api.Healthcheck)

    // 貴金属の価格を取得
    e.GET("/api/metal", api.FetchMetal)

    // ISINコードを引数に基準価格を取得
    // curl -sS localhost:1323/investment-trust/JP90C000GKC6
    e.GET("/api/investment-trust/:isin", api.FetchInvestTrust)

    // 仮想通貨の価格を取得
    e.GET("/api/crypto/gmo", cGmo.FetchCryptoGMO)
    e.GET("/api/crypto/coincheck", cCc.FetchCryptoCoincheck)

    // LocalとGAEの切り替え
    appengine.Main() // GAEの場合に有効化
    //e.Start(":1323") // Localの場合に有効化
}

base.go

共通する設定や関数を集約

package handler

import (
    "log"
    "strconv"
    "time"
)

const (
    timezone   = "Asia/Tokyo"
    offset     = 9 * 60 * 60
    timeFormat = "2006/01/02-15:04:05"
)

var (
    jst     = time.FixedZone(timezone, offset)
    nowTime = time.Now().In(jst).Format(timeFormat)
)

func convertStringToInt(value string) int {
    intValue, err := strconv.Atoi(value)
    if err != nil {
        log.Println("Error", err)
    }
    return intValue
}

investment_trust.go

package api

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strings"

    "github.com/PuerkitoBio/goquery"
    "github.com/labstack/echo/v4"
)

/* Response
{
    "base_price": 17569,
    "day_before_price": -461,
    "time": "2022/02/21-15:25:42"
}
*/

var (
    targetURL = "https://kakaku.com/fund/detail.asp?si_isin="
)

type InvestmentTrustInfo struct {
    BasePrice      int    `json:"base_price"`
    DayBeforePrice int    `json:"day_before_price"`
    Date           string `json:"time"`
}

func FetchInvestTrust(c echo.Context) error {
    var strBasePrice, strDayBeforePrice string
    isin := c.Param("isin")

    res, err := http.Get(targetURL + isin)
    if err != nil {
        log.Println(err)
    }
    defer res.Body.Close()

    doc, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        fmt.Println("error res.Body reader")
        log.Println(err)
    }

    doc.Find("div.infoMainTop.clearfix").Each(func(_ int, s *goquery.Selection) {
        strBasePrice = s.Children().Find("span.price").First().Text()
        plusPrice := s.Children().Find("span.plus").First().Text()   // 前日比がプラスの場合
        minusPrice := s.Children().Find("span.minus").First().Text() // 前日比の価格

        // htmlタグが結果で変化するため
        if minusPrice != "" {
            strDayBeforePrice = minusPrice
        } else if plusPrice != "" {
            strDayBeforePrice = plusPrice
        }
        if minusPrice != "" && plusPrice != "" {
            strDayBeforePrice = "0"
        }
    })

    // trim ","
    repCommaBasePrice := strings.Replace(strBasePrice, ",", "", -1)
    repCommaDayBeforePrice := strings.Replace(strDayBeforePrice, ",", "", -1)
    // trim "\n"
    repNewLineBasePrice := strings.Replace(repCommaBasePrice, "\n", "", -1)
    repNewLineDayBeforePrice := strings.Replace(repCommaDayBeforePrice, "\n", "", -1)
    // trim "*"
    repPlusDayBeforePrice := strings.Replace(repNewLineDayBeforePrice, "+", "", -1)

    // Convert string to int
    intBasePrice := convertStringToInt(repNewLineBasePrice)
    intDayBeforePrice := convertStringToInt(repPlusDayBeforePrice)

    // Set value to json
    responseJSON := InvestmentTrustInfo{
        BasePrice:      intBasePrice,
        DayBeforePrice: intDayBeforePrice,
        Date:           nowTime,
    }

    response, err := json.Marshal(responseJSON)
    if err != nil {
        log.Println(err)
    }
    return c.String(http.StatusOK, string(response))
}

■ 確認

❯ curl -sSu ${ID}:${PW} ${ENDPOINT}/investment-trust/JP90C0001MP2 | jq .
{
  "base_price": 10001,
  "day_before_price": 0,
  "time": "2022/02/22-21:27:41"
}

デプロイについて

今回のGoアプリはGoogle App Engineにデプロイしているので、Github Actionsで自動デプロイするようにしています。 そちらについては以前に記事でまとめています。

yhidetoshi.hatenablog.com

yhidetoshi.hatenablog.com

claspを利用してGitHub Actionsで Google App Script ( GAS )をデプロイする

はじめに

前回の記事で、google app script (GAS) の開発環境を整えました。なので今回はGASを自動デプロイする環境をGithub Actionsで用意します。

yhidetoshi.hatenablog.com

Github Actions

前回の記事にも記載していますが設定するディレクトリ構成は以下の通り

❯ tree -L 2 -a
.
├── .clasp.json
├── .claspignore
├── .github
 │   └── workflows
└── src
    ├── crypto.js
    ├── appsscript.json
  • claspでデプロイするために必要な3つのファイル
    • ~/.clasprc.json
    • .clasp.json
    • appsscript.json

github actionsの処理で 実行環境に3つのファイルを作成してから $clasp push コマンドを実行します。

これらのファイルには機密情報が含まれるので githubの secretにそれぞれ登録します。 環境変数名は以下にしました。

  • clasprc.json
    • ACCESS_TOKEN
    • ID_TOKEN
    • REFRESH_TOKEN
    • CLIENTID
    • CLIENTSECRET
  • clasp.json
    • SCRIPTID (機密扱いしなくてもいいかもですが念のために)
  • appsscript.json
    • LIBRARYID (機密扱いしなくてもいいかもですが念のために)

jsonファイルに出力する際に以下のようにエスケープする。

  • {\{
  • "\"
  • [\[

github-actionsのソースコード deploy.yaml は以下。

  • appsscript.json の配置場所に関しては、 .clasp.json"rootDir": "./src" と設定しているため。
  • github actionsの実行結果を見ても、 echoしていますが、secretの部分は *** でマスクされています。
 echo \{\"scriptId\":\"***\",\"rootDir\":\"./src\"\} > ./clasp.json
name: Deply to Google App Script
on:
  push:
    branches:
      - main

env:
  NODE_VERSION: '16'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - uses: actions/setup-node@v2
      with:
        node-version: ${{ env.NODE_VERSION }}

    - name: Install clasp
      run: |
        npm init -y
        npm install clasp -g

    - name: Setup clasprc.json
      run: echo \{\"token\":\{\"access_token\":\"${{ secrets.ACCESS_TOKEN }}\",\"scope\":\"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/script.projects https://www.googleapis.com/auth/script.webapp.deploy https://www.googleapis.com/auth/logging.read openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/script.deployments https://www.googleapis.com/auth/service.management https://www.googleapis.com/auth/cloud-platform\",\"token_type\":\"Bearer\",\"id_token\":\"${{ secrets.ID_TOKEN }}\",\"expiry_date\":1620870307822,\"refresh_token\":\"${{ secrets.REFRESH_TOKEN }}\"\},\"oauth2ClientSettings\":\{\"clientId\":\"${{ secrets.CLIENTID }}\",\"clientSecret\":\"${{ secrets.CLIENTSECRET }}\",\"redirectUri\":\"http://localhost\"\},\"isLocalCreds\":false\} > ~/.clasprc.json

    - name: Setup clasp.json
      run: echo \{\"scriptId\":\"${{ secrets.SCRIPTID }}\",\"rootDir\":\"./src\"\} > ./clasp.json

    - name: Setup appsscript.json
      run: echo \{\"timeZone\":\"Asia/Tokyo\",\"exceptionLogging\":\"STACKDRIVER\",\"runtimeVersion\":\"V8\",\"dependencies\":\{\"libraries\":\[\{\"userSymbol\":\"Parser\",\"version\":\"8\",\"libraryId\":\"${{ secrets.LIBRARYID }}\"\}\]\}\} > ./src/appsscript.json

    - name: Deploy
      run: clasp push

clasp で Google App Script ( GAS ) の開発環境を整えた

はじめに

Google App Script(GAS) でコーディングするにあたりローカルの開発環境をセッティングしたので備忘録です。 GASを実行するには、https://script.google.com/home でプロジェクトを作成してブラウザのエディタでコーディンングができます。 しかし、ブラウザ上でコーディンングするより使い慣れたエディタ(Visual Stdio Code)でやりたかったので clasp というgoogle製のCLIツールを 利用して環境を整えました。

設定

インストール

claspのgithuリポジトリ

github.com

node.jsが必要なのでインストールします。ここではインストールされている状態で以下をインストール。

npm init -y
npm install @google/clasp -g
npm install @types/google-apps-script

Googleアカウントに認証する。

clasp login --no-localhost

これを実行するとブラウザに画面が表示されていくので説明に応じて設定します。

設定で "Google Apps Script API" を有効化します。

f:id:yhidetoshi:20220215141154p:plain[

claspのセッティング

clasp でプロジェクトを作成

$ clasp create
Create which script? (Use arrow keys)
❯ standalone
├── .clasp.json
├── node_modules
├── package-lock.json
├── package.json
└── src
    ├── appsscript.json
    ├── crypto.js
  • .clasp.json
    • scriptId: 以下のプロジェクトの設定の値
    • rootDir: jsのソースコードを配置するパスを指定する
{
    "scriptId": "XXXXXXXXXXXXXXX",
    "rootDir": "./src"
}

f:id:yhidetoshi:20220215142003p:plain

  • appsscript.json は rootDirで指定したパスに配置する

claspのコマンド例

$ clasp open # webブラウザのエディタを開く
$ clasp push # コードをwebブラウザのエディタにアップロード
$ clasp pull # webブラウザのエディタのコードから取得する

参考

Goアプリを Google App Engine ( GAE ) に GitHub Actionsでデプロイする(Workload Identity 連携)

はじめに

前回の記事でGitHub ActionsでGoogle App Engine (GAE) にデプロイする時の認証にサービスアカウントの秘密鍵を利用しました。 そこで今回はOpenID Connectを利用して実行します。秘密鍵を管理する必要もなく、githubのsecretsに登録しないのでより安全に利用できると思います。

yhidetoshi.hatenablog.com

Google Cloudの設定( IAM と Workload Identity )

Google Cloud側で必要となる設定を以下で作成する。(任意に設定する名前を環境変数にセットして上から順番に実行していく)

# 設定変数を任意に設定
export PROJECT_ID="project-id"
export SERVICE_ACCOUNT_NAME="sa-account-name"
export WORKLOAD_IDENTITY_POOLS="wi-pools"
export WORKLOAD_IDENTITY_POOLS_DISPLAY="wi-pools-display"
export WORKLOAD_IDENTITY_PROVIDER_DISPLAY="wi-provider-display"
export OIDC_NAME="oidc-name"
export GITHUB_REPO="account/repo-name"

# サービスアカウントを作成
gcloud iam service-accounts create "${SERVICE_ACCOUNT_NAME}" \
  --project "${PROJECT_ID}" \
  --display-name "${SERVICE_ACCOUNT_NAME}"

# サービスアカウントに権限を付与
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --role="roles/iam.serviceAccountUser" \
  --member="serviceAccount:${SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"

# IAM Service Account Credentials APIを有効にする
gcloud services enable iamcredentials.googleapis.com \
  --project "${PROJECT_ID}"

# Workload Identity プールを作成
gcloud iam workload-identity-pools create "${WORKLOAD_IDENTITY_POOLS}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --display-name="${WORKLOAD_IDENTITY_POOLS_DISPLAY}"

# Workload IdentityプールのIDを取得
gcloud iam workload-identity-pools describe "${WORKLOAD_IDENTITY_POOLS}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --format="value(name)"

# Workload IdentityプールのIDを環境変数に設定
export WORKLOAD_IDENTITY_POOL_ID="上のレスポンス結果"

# Workload IdentityプールにWorkload Identityプロバイダを作成
gcloud iam workload-identity-pools providers create-oidc "${OIDC_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="${WORKLOAD_IDENTITY_POOLS}" \
  --display-name="${WORKLOAD_IDENTITY_PROVIDER_DISPLAY}" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
  --issuer-uri="https://token.actions.githubusercontent.com"

# 指定したGithubリポジトリで利用できるようにサービスアカウントに設定を追加
gcloud iam service-accounts add-iam-policy-binding "${SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${GITHUB_REPO}"

## Github Actionsで指定する値を確認 ##
# この結果を Github Actions (google-github-actions/auth)の 'workload_identity_provider' に設定する
gcloud iam workload-identity-pools providers describe "${OIDC_NAME}" \
  --project="${PROJECT_ID}" \
  --location="global" \
  --workload-identity-pool="${WORKLOAD_IDENTITY_POOLS}" \
  --format='value(name)'

# この結果を Github Actions (google-github-actions/auth)の 'service_account' に設定する
gcloud iam service-accounts describe "${SERVICE_ACCOUNT_NAME}"@"${PROJECT_ID}".iam.gserviceaccount.com --format="json" | jq .email -r
もしくは
gcloud iam service-accounts describe "${SERVICE_ACCOUNT_NAME}"@"${PROJECT_ID}".iam.gserviceaccount.com --format="yaml" | yq .email

作成したサービスアカウントに対して、GAEにデプロイするための権限を付与します。(google cloudへの認証だけであればこの権限追加は不要) f:id:yhidetoshi:20220214164245p:plain

GitHub Actionsについて

WORKLOAD_IDENTITY_PROVIDERGCP_SERVICE_ACCOUNTgithubのsecretsに登録する。 値については、上記の( "Google Cloudの設定( IAM と Workload Identity" の "Github Actionsで指定する値を確認") で確認 )

主な処理は以下の通り。

  1. Google Cloudに認証
  2. App Engineをデプロイ
  3. デプロイしたエンドポイントに curl で動作確認

■ 利用するGIthub-Actionsのライブラリ

name: Deploy Google App Engine
on:
  push:
    branches:
      - main
    paths:
      - ./**
      - .github/workflows/deploy.yaml

permissions:
  id-token: write
  contents: read


env:
  BASIC_AUTH_ID: ${{ secrets.BASIC_AUTH_ID }}
  BASIC_AUTH_PW: ${{ secrets.BASIC_AUTH_PW }}
  BASIC_AUTH_PATH: /metal
  WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
  GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }}


jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: 'actions/checkout@v2'
  
      - name: 'Authenticate to Google Cloud by OIDC'
         id: 'gcp-auth-oidc'
         uses: 'google-github-actions/auth@v0.6.0'
         with:
           workload_identity_provider: '${{ env.WORKLOAD_IDENTITY_PROVIDER }}'
           service_account: '${{ env.GCP_SERVICE_ACCOUNT }}'


      - name: 'Deploy to App Engine'
        id: 'deploy'
        uses: 'google-github-actions/deploy-appengine@v0.6.0'
        with:
          deliverables: 'app.yaml'
          promote: false
          version: 'v1'


      - name: 'validate'
        run: curl -sS -u ${{ env.BASIC_AUTH_ID }}:${{ env.BASIC_AUTH_PW }} ${{ steps.deploy.outputs.url }}${{ env.BASIC_AUTH_PATH }}

github actionsの実行結果 f:id:yhidetoshi:20220214164717p:plain

参考

Workload Identity 連携 について

cloud.google.com