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\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.