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 fromCountAPI [external website]

API reference

  • /api/{key} (namespace defaults todefault)
  • /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 deepcopyfrom requests import get, patch, postdef 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_responseif __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 onlocalhost:6379 will be modified

main.go:

package mainimport (    "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/22892986func 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-countergo 1.16require (    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=
askedMay 8, 2021 at 3:41
FromTheStackAndBack's user avatar
\$\endgroup\$

0

You mustlog in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.