Описание
Это простой (и надеюсь) RESTful API в Golang, использующий Redis. У меня практически не было опыта работы с Golang и абсолютно никакого опыта работы с Redis. API — это простой счетчик с функцией отключения «общедоступных» обновлений (не может создавать новый счетчик и не может увеличивать существующий счетчик)
Первоначальная идея была взята из CountAPI
Справочник по API
/api/{key}
(пространство имен по умолчаниюdefault
)/api/{namespace}/{key}
{namespace}
а также {key}
соответствовать регулярному выражению [a-zA-Z0-9_]+
На конечных точках поддерживаются следующие методы:
GET
: получить количество просмотровPOST
: увеличить количество просмотровPATCH
: обновить настройки пространства имен
Пример использования
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
выход:
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
Код
ПРЕДУПРЕЖДЕНИЕ: База данных Redis работает на localhost:6379
может быть изменен
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=