Build grammarbot client in Go

Welcome

While working with grammarbot, I decided to create my own command-line tool/client for working with API. As a language, I have chosen Golang. After that, I have added GitHub Action and gsutil. Also, I have configured Telegrams bot for notification purpose. Sounds like fun? For me definitely. So stop writing, and show me your code.

Tools used in this episode

  • Go
  • grammarbot.io API
  • GitHub Action
  • GCP
  • Telegram

Go

Go is an open-source programming language that makes it easy to build simple, reliable, and efficient software.

Why Go

It's open-source. It's fast, pleasant and readable language. Static compilation allows me to ship apps without problems. I just like Go.

Let's code - Go

  1. Install Go

    Everything is here. I think there is no need to provide additional information from my side.

  2. Setup a new project

    1# $Projects = working dir
    2# for me /home/kuba/Desktop/Projekty/
    3cd $Projects
    4mkdir grammarybot
    5
    6go mod init github.com/3sky/grammarybot-cli
    7# I like VSCode
    8code .
    
  3. Create main.go

      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    
    package main
    
    // only standard libs
    import (
        "encoding/json"
        "errors"
        "flag"
        "fmt"
        "io/ioutil"
        "net/http"
        "os"
        "time"
    )
    
    //FreePlanLimit limit of chrackter in free plan
    //grammarbot limit is 50000 char
    const FreePlanLimit = 50000
    
    type stop struct {
        error
    }
    
    func main() {
        // Constants variables - I like CAPS style
        LANGAUGE := "en-US"
        URL := "http://api.grammarbot.io/v2/check"
        // CLI flag declatarion
        botToken := flag.String("token", "XYZ", "Grammarbot token")
        pathToFile := flag.String("path", "", "Path to file")
        flag.Parse()
        //loading file to check
        text, err := LoadFile(*pathToFile)
        if err != nil {
            fmt.Println(err)
        }
        // usage retry function becouse of
        // Internall Server Error
        err = retry(3, time.Second*3, func() error {
            return CheckText(LANGAUGE, URL, *botToken, text)
        })
        if err != nil {
            fmt.Printf("checkText error %v", err)
        }
    
    }
    
    //LoadFile load file and check against planlimit
    func LoadFile(path string) (string, error) {
        pwd, err := os.Getwd()
        defer func() {
            if err != nil {
                fmt.Fprintf(os.Stderr, "Fatal panic: %v", err)
                os.Exit(1)
            }
        }()
        content, err := ioutil.ReadFile(pwd + "/" + path)
        defer func() {
            if err != nil {
                fmt.Fprintf(os.Stderr, "Fatal panic: %v", err)
                os.Exit(1)
            }
        }()
        text := string(content)
        defer func() {
            if len(text) > FreePlanLimit {
                fmt.Fprintf(os.Stderr, "Test is to long: %d", len(text))
                os.Exit(1)
            }
        }()
        return text, nil
    }
    //CheckText send text to grammary
    func CheckText(lang, url, token, text string) error {
        var client http.Client
        var data ResponseStruct
        req, err := http.NewRequest("POST", url, nil)
        if err != nil {
            return err
        }
        q := req.URL.Query()
        q.Add("api_key", token)
        q.Add("language", lang)
        q.Add("text", text)
        req.URL.RawQuery = q.Encode()
        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        if resp.StatusCode != 200 {
            return errors.New("Internal GrammaryBot Error")
        }
        err = json.NewDecoder(resp.Body).Decode(&data)
        if err != nil {
            return err
        }
        x, err := json.MarshalIndent(data.Matches, "", "\t")
        if err != nil {
            return err
        }
        // empty len((string(x)) == 2
        if len(string(x)) <= 2 {
            fmt.Println("Text is OK")
        } else {
            fmt.Println(string(x))
        }
        return nil
    }
    
    // to avoid Internal Server Error from GrammaryBot side
    func retry(attempts int, sleep time.Duration, fn func() error) error {
        if err := fn(); err != nil {
            if s, ok := err.(stop); ok {
                return s.error
            }
            if attempts--; attempts > 0 {
                fmt.Printf("Take a try: %d", attempts)
                time.Sleep(sleep)
                return retry(attempts, 2*sleep, fn)
            }
            return err
        }
        return nil
    }
    
  4. Define structs.go in a separate file Nothing special file is in repo

  5. Define some basic tests main_test.go

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    
    package main
    
    import (
        "strings"
        "testing"
    )
    
    func TestCheckText(t *testing.T) {
        LANGAUGE := "en-US"
        URL := "http://api.grammarbot.io/v2/check"
        botToken := "XYZ"
        text := "I can't remember how to go their"
        err := CheckText(LANGAUGE, URL, botToken, text)
        if err != nil {
            t.Errorf("Error with CheckText funtion")
        }
    }
    
    func TestLoadFile(t *testing.T) {
        PATH := "go.mod"
        str, err := LoadFile(PATH)
        if err != nil {
            t.Errorf("Error with TestLoadFile funtion")
        }
        if !(strings.Contains(str, "github.com/3sky/grammarybot-cli")) {
            t.Errorf("Error with TestLoadFile, string is wrong")
        }
    }
    
  6. Run test

    1➜  grammarybot go test ./...
    2ok      github.com/3sky/grammarybot-cli 0.914s
    3➜  grammarybot
    
  7. OK, now build app make some real test

    1go build -o grammary-cli .
    

    Then:

    1./grammary-cli -token XYZ -path tmp/how-to-gp-1.md
    

    And works, but the output is very long so I passed only a part:

     1...
     2  {
     3        "message": "Possible typo: you repeated a whitespace",
     4        "shortMessage": "",
     5        "replacements": [
     6                {
     7                        "value": " "
     8                }
     9        ],
    10        "offset": 3975,
    11        "length": 4,
    12        "context": {
    13                "text": "...y the Terraform-managed infrastructure
    14                      `WARNING` - At the end of learning     s...",
    15                "offset": 43,
    16                "length": 4
    17        },
    18        "sentence": "`WARNING` - At the end of learning\n
    19                    session destroy unused infrastructure - it's cheaper",
    20        "type": {
    21                "typeName": "Other"
    22        },
    23        "rule": {
    24                "id": "WHITESPACE_RULE",
    25                "description": "Whitespace repetition (bad formatting)",
    26                "issueType": "whitespace",
    27                "category": {
    28                        "id": "TYPOGRAPHY",
    29                        "name": "Typography"
    30                }
    31        }
    32    },
    33...
    

Summary - Go

That was a nice phase. I very enjoy writing Go code. Maybe I'm not the best coder, but the tool works ;) I define some flags, basic tests, and the application to do what should do. Tool is fast, 500 error type ready and portable. Token is provided as a parameter, so there is no hardcodes.

GitHub Action

Let's code - GitHub Action

  1. Create .github/workflows/main.yml

    That will be the pipeline for the deploy app to Google Storage. So the sceleton will be:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    on: [push]
    name: grammary-cli
    jobs:
    build:
        runs-on: ubuntu-latest
        steps:
        - name: Install Go
          uses: actions/setup-go@v1
          with:
            go-version: 1.14.x
        - name: Setup GCP # Install GCP stuff
        - name: verify gsutil installation # Verify instalation
        - name: Checkout code
          uses: actions/checkout@v2
        - name: Test
          run: go test ./... -v
        - name: Build
          run: go build -o grammary-cli
        - name: Deploy  # Deploy binary
        - name: notify # Send notification
    

Google Cloud Platform

Let's code - GCP

  1. Install tools on runner

    In GitHub Action we have ready actions avalaible in market. So I decided to use one.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    - name: Setup GCP
      uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
      with:
        version: '281.0.0'
        service_account_email: ${{ secrets.GCP_SA_EMAIL }}
        service_account_key: ${{ secrets.GCP_SA_KEY }}
        export_default_credentials: true
    - name: verify gsutil instalation
      run: gsutil ls -p tokyo-baton-256120
    

    That snipped contains two secrets secrets.GCP_SA_KEY and secrets.GCP_SA_EMAIL.
    To get this value I need to create IAM role for Google Storage Access. I highly recommend this docs. Then when I get auth.json I can go forward. \

    GCP_SA_EMAIL - client_email from auth.json
    GCP_SA_KEY it's whole encoded file

    1cat auth.json | base64
    

Gsutil

Let's code - Gsutil

  1. Deploy binary to Storage

    1
    2
    3
    4
    
    - name: Deploy
      run: |
        ls -lR
        gsutil cp grammary-cli gs://grammarybot-cli    
    

    gsutil is very similar to sftp command. So syntax is easy

    1gsutil cp [OPTION]... src_url dst_url
    2gsutil cp [OPTION]... src_url... dst_url
    3gsutil cp [OPTION]... -I dst_url
    

Telegram

Let's code - Telegram

  1. Telegram configuration

    1. type /help

    2. type /newbot

    3. generate bot name like superbot, not unique

    4. generate bot username like super-uniqe-bot, must be unique

    5. get a token /token

    6. save token

    7. subscribe bot

    8. use REST API to received TELEGRAM_TO

      1curl -s https://api.telegram.org/bot<token>/getUpdates | jq.
      2
      3# example URL
      4# https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getUpdates
      
    9. TELEGRAM_TO is field chat.id

  2. Configure notification

    I would like to get some notification after the build. Telegram is a nice tool, and there is already created GH Action.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    - name: test telegram notification
      uses: appleboy/telegram-action@master
      with:
        to: ${{ secrets.TELEGRAM_TO }}
        token: ${{ secrets.TELEGRAM_TOKEN }}
        message: |
          Hello my Master
          Build number ${{ github.run_number }}
          of ${{ github.repository }} is complete ;)      
    

    That snipped contains two secrets secrets.TELEGRAM_TO and secrets.TELEGRAM_TOKEN. Again I can recommend this docs. But I received this value in the previous section. \

    There are also two context variable github.run_number and github.repository. And again docs are more than enough.

Final main.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
on: [push]
name: grammary-cli
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - name: Install Go
      uses: actions/setup-go@v1
      with:
        go-version: 1.14.x
    - name: Setup GCP
      uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
      with:
        version: '281.0.0'
        service_account_email: ${{ secrets.GCP_SA_EMAIL }}
        service_account_key: ${{ secrets.GCP_SA_KEY }}
        export_default_credentials: true
    - name: verify gsutil instalation
      run: gsutil ls -p tokyo-baton-256120
    - name: Checkout code
      uses: actions/checkout@v2
    - name: Test
      run: go test ./... -v
    - name: Build
      run: go build -o grammary-cli
    - name: Deploy
      run: |
        ls -lR
        gsutil cp grammary-cli gs://grammarybot-cli        
    - name: test telegram notification
      uses: appleboy/telegram-action@master
      with:
        to: ${{ secrets.TELEGRAM_TO }}
        token: ${{ secrets.TELEGRAM_TOKEN }}
        message: |
          Hello my Master
          Build number ${{ github.run_number }}
          of ${{ github.repository }} is complete ;)          

Push all code

  1. Add repo

    1git init
    2git remote add origin [email protected]:<user>/<reponame>.git
    3
    4# Example
    5# git remote add origin [email protected]:3sky/grammarybot-cli.git
    
  2. Commit changes and push it

    1git add -A
    2git commit -m 'init commmit'
    3git push origin master
    

Final

That was a long journey, but it's working at least in my environment :) Whole post contains useful information about small tool's delivery pipeline. It was fun to work with all these products and resolving different problems. GH Action is still awesome, Telegram bots are easy to setup when botFather works. Because it's not obvious, sometimes is just overloaded. Finding a free username is also hard. For me, very helpful was the name generator based on food and job titles. Google Cloud Platform delivers a nice IAM policy, so there wasn't a problem with configuration. Gsutil it's just a command-line tool, so it works as should. To summarize programming and codding is easier than writing human-readable blog posts :)