4
\$\begingroup\$

Description

This is a simple (and hopefully) RESTful API in Golang that uses Redis. I have next to no prior experience in Golang and absolutely no prior experience with Redis. The API is a simple counter service with the feature of disabling "public" updates (cannot create new counter, and cannot increment existing counter)

The original idea was taken from CountAPI [external website]

API reference

  • /api/{key} (namespace defaults to default)
  • /api/{namespace}/{key}

{namespace} and {key} match the regex [a-zA-Z0-9_]+

The following methods are supported on the endpoints:

  • GET: get view count
  • POST: increment view count
  • PATCH: update namespace settings

Example usage

test.py:

from copy import deepcopy
from requests import get, patch, post


def test_expected(method, url, expected, ignored_fields = [], headers = {}, data = {}):
    response = method(url, headers=headers, data=data).json()
    original_response = deepcopy(response)

    print(f'{method.__name__.upper()} {url}')
    print(f'    {response}\n')

    for ignored_field in ignored_fields:
        try:
            response.pop(ignored_field)
        except KeyError:
            pass

        try:
            expected.pop(ignored_field)
        except KeyError:
            pass

    assert response == expected, f'got {response}, expected {expected}'

    return original_response


if __name__ == '__main__':
    # Get view count
    test_expected(get, 'http://localhost:8080/api/default/key', {'success': True, 'data': 0})
    test_expected(get, 'http://localhost:8080/api/namespace/key', {'success': True, 'data': 0})
    test_expected(get, 'http://localhost:8080/api/default_namespace_key', {'success': True, 'data': 0})

    # Create new key ("default/key") in namespace that is public (is_public == true)
    test_expected(post, 'http://localhost:8080/api/default/key', {'success': True, 'data': 1, 'isNewNamespace': False})

    # Create new key in non-existent namespace
    response = test_expected(post, 'http://localhost:8080/api/namespace/key', {'success': True, 'data': 1, 'isNewNamespace': True}, ignored_fields=['overrideKey'])
    key = response.get('overrideKey')

    # Check that namespace is not public by default
    test_expected(post, 'http://localhost:8080/api/namespace/key', {'success': False, 'error': 'No permission to update namespace/key'})

    # Check that key in private namespace can be accessed with override key
    test_expected(post, 'http://localhost:8080/api/namespace/key', {'success': True, 'data': 2, 'isNewNamespace': False}, headers={'X-Override-Key': key})

    test_expected(patch, 'http://localhost:8080/api/namespace/key', {'success': False, 'error': 'Incorrect override key'})
    test_expected(patch, 'http://localhost:8080/api/namespace/key', {'success': False, 'error': 'Incorrect override key'}, headers={'X-Override-Key': 'foobar'})
    test_expected(patch, 'http://localhost:8080/api/namespace/key', {'success': False, 'error': 'Field to update does not exist'}, headers={'X-Override-Key': key})
    test_expected(patch, 'http://localhost:8080/api/namespace/key', {'success': True}, headers={'X-Override-Key': key}, data={'field': 'is_public', 'newValue': 'true', 'valueType': 'bool'})

    # Check that everything still works as expected
    test_expected(get, 'http://localhost:8080/api/namespace/key', {'success': True, 'data': 2})
    test_expected(post, 'http://localhost:8080/api/namespace/key', {'success': True, 'data': 3, 'isNewNamespace': False})
    test_expected(patch, 'http://localhost:8080/api/namespace/key', {'success': True}, headers={'X-Override-Key': key}, data={'field': 'is_public', 'newValue': 'false', 'valueType': 'bool'})
    test_expected(get, 'http://localhost:8080/api/namespace/key', {'success': True, 'data': 3})
    test_expected(post, 'http://localhost:8080/api/namespace/key', {'success': False, 'error': 'No permission to update namespace/key'})

    print('All tests passed')

test.py output:

GET http://localhost:8080/api/default/key
    {'success': True, 'data': 0}

GET http://localhost:8080/api/namespace/key
    {'success': True, 'data': 0}

GET http://localhost:8080/api/default_namespace_key
    {'success': True, 'data': 0}

POST http://localhost:8080/api/default/key
    {'success': True, 'data': 1, 'isNewNamespace': False}

POST http://localhost:8080/api/namespace/key
    {'success': True, 'data': 1, 'isNewNamespace': True, 'overrideKey': '+2yubkGY1GwgfIJA'}

POST http://localhost:8080/api/namespace/key
    {'success': False, 'error': 'No permission to update namespace/key'}

POST http://localhost:8080/api/namespace/key
    {'success': True, 'data': 2, 'isNewNamespace': False}

PATCH http://localhost:8080/api/namespace/key
    {'success': False, 'error': 'Incorrect override key'}

PATCH http://localhost:8080/api/namespace/key
    {'success': False, 'error': 'Incorrect override key'}

PATCH http://localhost:8080/api/namespace/key
    {'success': False, 'error': 'Field to update does not exist'}

PATCH http://localhost:8080/api/namespace/key
    {'success': True}

GET http://localhost:8080/api/namespace/key
    {'success': True, 'data': 2}

POST http://localhost:8080/api/namespace/key
    {'success': True, 'data': 3, 'isNewNamespace': False}

PATCH http://localhost:8080/api/namespace/key
    {'success': True}

GET http://localhost:8080/api/namespace/key
    {'success': True, 'data': 3}

POST http://localhost:8080/api/namespace/key
    {'success': False, 'error': 'No permission to update namespace/key'}

All tests passed

Code

WARNING: Redis database running on localhost:6379 will be modified

main.go:

package main

import (
    "encoding/json"
    "log"
    "math/rand"
    "net/http"
    "strconv"
    "strings"
    "time"

    "github.com/gomodule/redigo/redis"
    "github.com/gorilla/mux"
)

var (
    c   redis.Conn
    err error
)

const defaultNamespace = "default"

func apiMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Print(strings.Join([]string{r.RemoteAddr, r.Method, r.URL.Path}, " "))

        w.Header().Set("Content-Type", "application/json")
        next.ServeHTTP(w, r)
    })
}

func mainErrorHandler(w http.ResponseWriter, r *http.Request) {
    const page = `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Not Found</title>
  </head>
  <body>
    <h1>Not Found</h1>
  </body>
</html>`
    w.Write([]byte(page))
}

func getNamespaceAndKey(m map[string]string) (string, string) {
    namespace := m["namespace"]

    if namespace == "" {
        namespace = defaultNamespace
    }

    key := m["key"]

    return namespace, key
}

func incrementViewCount(namespace string, key string) int {
    val, err := c.Do("INCR", namespace+":"+key)
    if err != nil {
        log.Fatal(err)
    }

    n, err := redis.Int(val, err)
    if err != nil {
        log.Fatal(err)
    }

    return n
}

func getViewCount(namespace string, key string) int {
    val, err := c.Do("GET", namespace+":"+key)
    if err != nil {
        log.Fatal(err)
    }

    if val == nil {
        return 0
    }

    n, err := redis.Int(val, err)
    if err != nil {
        log.Fatal(err)
    }

    return n
}

func apiErrorHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    type Response struct {
        Success bool   `json:"success"`
        Error   string `json:"error"`
    }

    res := Response{Success: false, Error: "Invalid route"}
    json.NewEncoder(w).Encode(res)
}

func apiGetNumViewsHandler(w http.ResponseWriter, r *http.Request) {
    type Response struct {
        Success bool `json:"success"`
        Data    int  `json:"data"`
    }

    vars := mux.Vars(r)

    namespace, key := getNamespaceAndKey(vars)

    n := getViewCount(namespace, key)

    res := Response{Success: true, Data: n}
    json.NewEncoder(w).Encode(res)
}

func namespaceExists(namespace string) bool {
    namespaceExists, err := redis.Bool(c.Do("EXISTS", namespace))
    if err != nil {
        log.Fatal(err)
    }

    return namespaceExists
}

func allowedToUpdateKey(namespace string, key string, userOverrideKey string) bool {
    if !namespaceExists(namespace) {
        return true
    }

    overrideKey, err := redis.String(c.Do("HGET", namespace, "override_key"))
    if err != nil {
        log.Fatal(err)
    }

    if userOverrideKey == overrideKey {
        return true
    }

    namespacePublic, err := redis.Bool(c.Do("HGET", namespace, "is_public"))
    if err != nil {
        log.Fatal(err)
    }

    return namespacePublic
}

var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-+")

// From https://stackoverflow.com/a/22892986
func randomOverrideKey(n int) string {
    b := make([]rune, n)
    for i := range b {
        b[i] = letters[rand.Intn(len(letters))]
    }
    return string(b)
}

func createNamespace(namespace string) string {
    overrideKey := randomOverrideKey(16)

    _, err := redis.Int(c.Do("HSET", namespace, "is_public", false, "override_key", overrideKey))
    if err != nil {
        log.Fatal(err)
    }

    return overrideKey
}

func apiPingHandler(w http.ResponseWriter, r *http.Request) {
    type Response struct {
        Success      bool   `json:"success"`
        Data         int    `json:"data"`
        NewNamespace bool   `json:"isNewNamespace"`
        OverrideKey  string `json:"overrideKey,omitempty"`
    }

    type ErrorResponse struct {
        Success bool   `json:"success"`
        Error   string `json:"error"`
    }

    vars := mux.Vars(r)
    namespace, key := getNamespaceAndKey(vars)
    userOverrideKey := r.Header.Get("X-Override-Key")

    if !allowedToUpdateKey(namespace, key, userOverrideKey) {
        res := ErrorResponse{Success: false, Error: "No permission to update " + namespace + "/" + key}
        json.NewEncoder(w).Encode(res)
        return
    }

    isNewNamespace := !namespaceExists(namespace)

    var overrideKey string

    if isNewNamespace {
        overrideKey = createNamespace(namespace)
    } else {
        overrideKey = ""
    }

    n := incrementViewCount(namespace, key)

    res := Response{Success: true, Data: n, NewNamespace: isNewNamespace, OverrideKey: overrideKey}
    json.NewEncoder(w).Encode(res)
}

func convertValueType(value string, valueType string) interface{} {
    var convertedValue interface{}

    // Other types at https://pkg.go.dev/github.com/gomodule/redigo/redis#pkg-index
    // Conversion at https://github.com/gomodule/redigo/blob/72af8129e040d6f962772a8c582e5e9f22085788/redis/reply.go
    switch valueType {
    case "string":
        convertedValue, err = redis.String(value, err)
        if err != nil {
            log.Fatalf(`Could not convert "%v" to type "%v" (invalid value): %v`, value, valueType, err)
        }
    case "bool":
        convertedValue, err = redis.Bool([]byte(value), err)
        if err != nil {
            log.Fatalf(`Could not convert "%v" to type "%v" (invalid value): %v`, value, valueType, err)
        }
    case "int":
        convertedValue, err = redis.Int([]byte(value), err)
        if err != nil {
            log.Fatalf(`Could not convert "%v" to type "%v" (invalid value): %v`, value, valueType, err)
        }
    default:
        log.Fatalf(`Could not convert "%v" to type "%v" (unknown type)`, value, valueType)
    }

    return convertedValue
}

func apiUpdateHandler(w http.ResponseWriter, r *http.Request) {
    userOverrideKey := r.Header.Get("X-Override-Key")

    vars := mux.Vars(r)
    namespace, _ := getNamespaceAndKey(vars)

    overrideKey, err := redis.String(c.Do("HGET", namespace, "override_key"))
    if err != nil {
        log.Fatal(err)
    }

    type Response struct {
        Success bool `json:"success"`
    }

    type ErrorResponse struct {
        Success bool   `json:"success"`
        Error   string `json:"error"`
    }

    if userOverrideKey != overrideKey {
        res := ErrorResponse{Success: false, Error: "Incorrect override key"}
        json.NewEncoder(w).Encode(res)
        return
    }

    field := r.FormValue("field")
    value := r.FormValue("newValue")
    valueType := r.FormValue("valueType")

    exists, err := redis.Int(c.Do("HEXISTS", namespace, field))
    if err != nil {
        log.Fatal(err)
    }

    if exists == 0 {
        res := ErrorResponse{Success: false, Error: "Field to update does not exist"}
        json.NewEncoder(w).Encode(res)
        return
    }

    convertedValue := convertValueType(value, valueType)

    _, err = redis.Int(c.Do("HSET", namespace, field, convertedValue))
    if err != nil {
        log.Fatal(err)
    }

    res := Response{Success: true}
    json.NewEncoder(w).Encode(res)
}

func init() {
    c, err = redis.Dial("tcp", ":6379")
    if err != nil {
        log.Fatal(err)
    }
    log.Print("Connected to database")

    s := time.Now().UnixNano()
    rand.Seed(s)
    log.Print("Seeded with " + strings.ToUpper(strconv.FormatInt(s, 16)))

    createNamespace(defaultNamespace)
    _, err = c.Do("HSET", defaultNamespace, "is_public", convertValueType("true", "bool"))
    if err != nil {
        log.Fatal(err)
    }
}

func main() {
    defer c.Close()

    r := mux.NewRouter()
    r.NotFoundHandler = http.HandlerFunc(mainErrorHandler)

    s := r.PathPrefix("/api/").Subrouter()
    s.Use(apiMiddleware)

    allowedCharsRegex := "[a-zA-Z0-9_]+"

    s.Path("/{key:" + allowedCharsRegex + "}").HandlerFunc(apiGetNumViewsHandler).Methods("GET")
    s.Path("/{namespace:" + allowedCharsRegex + "}/{key:" + allowedCharsRegex + "}").HandlerFunc(apiGetNumViewsHandler).Methods("GET")

    s.Path("/{key:" + allowedCharsRegex + "}").HandlerFunc(apiPingHandler).Methods("POST")
    s.Path("/{namespace:" + allowedCharsRegex + "}/{key:" + allowedCharsRegex + "}").HandlerFunc(apiPingHandler).Methods("POST")

    s.Path("/{key:" + allowedCharsRegex + "}").HandlerFunc(apiUpdateHandler).Methods("PATCH")
    s.Path("/{namespace:" + allowedCharsRegex + "}/{key:" + allowedCharsRegex + "}").HandlerFunc(apiUpdateHandler).Methods("PATCH")

    s.NotFoundHandler = http.HandlerFunc(apiErrorHandler)
    log.Print("Routes set up successfully")

    http.ListenAndServe(":8080", r)
}

go.mod:

module hit-counter

go 1.16

require (
    github.com/gomodule/redigo v1.8.4
    github.com/gorilla/mux v1.8.0
)

go.sum:

github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gomodule/redigo v1.8.4 h1:Z5JUg94HMTR1XpwBaSH4vq3+PNSIykBLxMdglbw10gg=
github.com/gomodule/redigo v1.8.4/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
github.com/gomodule/redigo/redis v0.0.0-do-not-use h1:J7XIp6Kau0WoyT4JtXHT3Ei0gA1KkSc6bc87j9v9WIo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.