initial
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"15-puzzle/internal/model"
|
||||
"15-puzzle/internal/validator"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ctxUserID string
|
||||
|
||||
const (
|
||||
WebAppInitDataHeader = "Web-App-Init-Data"
|
||||
WebAppExtraCodeHeader = "Web-App-Extra-Code"
|
||||
WebAppHtmlFile = "tgwebapp.html"
|
||||
ctxDataUserID ctxUserID = "user_id"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
RegisterGameStart(UserID int) (model.User, error)
|
||||
RegisterGameSolve(UserID, moves int) (model.User, error)
|
||||
Stats(UserID int) (model.User, error)
|
||||
Monitoring() (model.Monitoring, error)
|
||||
Rating() []int
|
||||
}
|
||||
|
||||
func NewHandler(repo Repository, token, code, ctxRoot, staticDir, projectLink string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if abs, err := filepath.Abs(staticDir); err == nil {
|
||||
staticDir = abs
|
||||
}
|
||||
|
||||
mux.Handle(http.MethodGet+" /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))))
|
||||
mux.Handle(http.MethodGet+" /puzzle.html", staticFileHandler(path.Join(staticDir, WebAppHtmlFile)))
|
||||
|
||||
apiMux := http.NewServeMux()
|
||||
apiMux.Handle(http.MethodGet+" /info", apiInfoHandler(model.Info{ProjectLink: projectLink}))
|
||||
apiMux.Handle(http.MethodPut+" /start", apiStartHandler(repo))
|
||||
apiMux.Handle(http.MethodPut+" /solve", apiSolveHandler(repo))
|
||||
apiMux.Handle(http.MethodGet+" /stats", apiStatsHandler(repo))
|
||||
apiMux.Handle(http.MethodGet+" /monitoring", apiMonitoringHandler(repo, code))
|
||||
apiKey := validator.EncodeHmacSha256([]byte(token), []byte("WebAppData"))
|
||||
mux.Handle("/api/", authHandler(apiKey, http.StripPrefix("/api", apiMux)))
|
||||
|
||||
root := http.NewServeMux()
|
||||
root.Handle(strings.TrimRight(ctxRoot, "/")+"/", http.StripPrefix(strings.TrimRight(ctxRoot, "/"), mux))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func staticFileHandler(name string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, name)
|
||||
})
|
||||
}
|
||||
|
||||
func authHandler(apiKey []byte, h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := apiKey
|
||||
if v, ok := r.Header[WebAppInitDataHeader]; !ok || len(v) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
} else if ok, userID, err := validator.ValidUser(key, v[0]); err != nil {
|
||||
slog.Error(fmt.Sprintf("validator: %s", err))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
} else if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
} else {
|
||||
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxDataUserID, userID)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func apiInfoHandler(i model.Info) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeResponse(w, model.ApiResponse{Info: &i})
|
||||
})
|
||||
}
|
||||
|
||||
func apiStartHandler(repo Repository) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, r, repo.Rating, repo.RegisterGameStart)
|
||||
})
|
||||
}
|
||||
|
||||
func apiSolveHandler(repo Repository) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v := r.URL.Query().Get("moves")
|
||||
if moves, err := strconv.Atoi(r.URL.Query().Get("moves")); err != nil {
|
||||
errorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid value: %q", v))
|
||||
} else {
|
||||
respond(w, r, repo.Rating, func(u int) (model.User, error) { return repo.RegisterGameSolve(u, moves) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func apiStatsHandler(repo Repository) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, r, repo.Rating, repo.Stats)
|
||||
})
|
||||
}
|
||||
|
||||
func apiMonitoringHandler(repo Repository, code string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if code == "" || code != r.Header.Get(WebAppExtraCodeHeader) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
m, err := repo.Monitoring()
|
||||
if err != nil {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("fetch monitoring: %s", err))
|
||||
return
|
||||
}
|
||||
writeResponse(w, model.ApiResponse{Monitoring: &m})
|
||||
})
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, r *http.Request, rating func() []int, action func(int) (model.User, error)) {
|
||||
userID, ok := r.Context().Value(ctxDataUserID).(int)
|
||||
if !ok {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("unsupported context value type: %T", userID))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := action(userID)
|
||||
if err != nil {
|
||||
if u.UserID == 0 {
|
||||
errorResponse(w, http.StatusNotFound, fmt.Errorf("user_id=%d not found", userID))
|
||||
} else {
|
||||
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("user_id=%d game action: %s", userID, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
stats := &model.Stats{
|
||||
GamesStarted: u.GamesStarted,
|
||||
GamesSolved: u.GamesSolved,
|
||||
Rank: rankPosition(userID, rating()),
|
||||
}
|
||||
|
||||
writeResponse(w, model.ApiResponse{Stats: stats, Monitoring: u.Monitoring})
|
||||
}
|
||||
|
||||
func rankPosition(UserID int, rating []int) int {
|
||||
for i, uid := range rating {
|
||||
if uid == UserID {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func errorResponse(w http.ResponseWriter, code int, err error) {
|
||||
slog.Error(err.Error())
|
||||
w.WriteHeader(code)
|
||||
s := err.Error()
|
||||
writeResponse(w, model.ApiResponse{Err: &s})
|
||||
}
|
||||
|
||||
func writeResponse(w http.ResponseWriter, r model.ApiResponse) {
|
||||
b, err := json.Marshal(&r)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("response json marshal: %s", err)
|
||||
slog.Error(msg)
|
||||
b = []byte(msg)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
if _, err := w.Write(b); err != nil {
|
||||
slog.Error(fmt.Sprintf("send json response: %s", err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"15-puzzle/internal/model"
|
||||
"15-puzzle/internal/repo"
|
||||
"15-puzzle/internal/web-service/handler"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
botToken = "example:token"
|
||||
initData = "auth_date=269666017&chat_instance=6039284203686499081&chat_type=sender&hash=40cde8dc7250ee616cd7d7a090749a9a42cc68f018c969fcb48fdf5e62657ad6&signature=FF5oTJSnmxdqgtNozxsLywXyVKdssh_DbvksGUaQuhkMiRfp10HJmf5o88uokPpqF4yhpHbX1c8uLbrKUuUdAA&user=%7B%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22Ilia%22%2C%22id%22%3A303133707%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22Denisov%22%2C%22photo_url%22%3A%22https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ%22%7D"
|
||||
userId = 303133707
|
||||
)
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
// static
|
||||
testCase(t, testStaticExistingFile)
|
||||
testCase(t, testStaticWebAppIndex)
|
||||
// api
|
||||
testCase(t, testApiInfo)
|
||||
testCase(t, testApiAuth)
|
||||
testCase(t, testApiStart)
|
||||
testCase(t, testApiSolve)
|
||||
testCase(t, testApiStats)
|
||||
testCase(t, testApiMonitoring)
|
||||
}
|
||||
|
||||
func testStaticExistingFile(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ctxRoot+"/static/tgwebapp.html", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "static file request must succeed")
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
assert.Contains(t, w.Body.String(), "unit-test-header")
|
||||
}
|
||||
|
||||
func testStaticWebAppIndex(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ctxRoot+"/puzzle.html", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "redirect to static file must succeed")
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
assert.Contains(t, w.Body.String(), "unit-test-header")
|
||||
}
|
||||
|
||||
func testApiAuth(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/info", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, ctxRoot+"/api/start", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, ctxRoot+"/api/solve?moves=69", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, ctxRoot+"/api/stats", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, ctxRoot+"/api/monitoring", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func testApiInfo(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/info", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok")
|
||||
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
var u model.ApiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil {
|
||||
t.Fatalf("decode json %s: %s", w.Body.String(), err)
|
||||
}
|
||||
assert.NotNil(t, u.Info, "response: stats field should be set")
|
||||
}
|
||||
|
||||
func testApiStart(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, ctxRoot+"/api/start", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok")
|
||||
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
var u model.ApiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil {
|
||||
t.Fatalf("decode json %s: %s", w.Body.String(), err)
|
||||
}
|
||||
assert.NotNil(t, u.Stats, "response: stats field should be set")
|
||||
assert.Equal(t, 1, u.Stats.GamesStarted, "user started games should be exactly one")
|
||||
}
|
||||
|
||||
func testApiSolve(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, ctxRoot+"/api/solve", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, ctxRoot+"/api/solve?moves=69", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok")
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
var u model.ApiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil {
|
||||
t.Fatalf("decode json %s: %s", w.Body.String(), err)
|
||||
}
|
||||
assert.NotNil(t, u.Stats, "response: stats field should be set")
|
||||
assert.Equal(t, 1, u.Stats.GamesSolved, "user solved games should be exactly one")
|
||||
}
|
||||
|
||||
func testApiStats(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/stats", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok")
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
var u model.ApiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil {
|
||||
fmt.Println(w.Body)
|
||||
t.Fatalf("decode json %s: %s", w.Body.String(), err)
|
||||
}
|
||||
assert.NotNil(t, u.Stats, "response: stats field should be set")
|
||||
}
|
||||
|
||||
func testApiMonitoring(t *testing.T, ctxRoot string, h http.Handler) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/monitoring", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.True(t, w.Body.Len() == 0)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, ctxRoot+"/api/monitoring", nil)
|
||||
req.Header.Add(handler.WebAppInitDataHeader, initData)
|
||||
req.Header.Add(handler.WebAppExtraCodeHeader, "1234")
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "application/json")
|
||||
var u model.ApiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil {
|
||||
t.Fatalf("decode json %s: %s", w.Body.String(), err)
|
||||
}
|
||||
assert.NotNil(t, u.Monitoring, "monitoring field should be an object")
|
||||
assert.NotNil(t, u.Monitoring.Users)
|
||||
assert.NotNil(t, u.Monitoring.GamesStarted)
|
||||
assert.NotNil(t, u.Monitoring.GamesSolved)
|
||||
}
|
||||
|
||||
func testCase(t *testing.T, tc func(*testing.T, string, http.Handler)) {
|
||||
testContextRoot(t, "", tc)
|
||||
testContextRoot(t, "/", tc)
|
||||
testContextRoot(t, "/15-puzzle/", tc)
|
||||
testContextRoot(t, "/15-puzzle", tc)
|
||||
}
|
||||
|
||||
func testContextRoot(t *testing.T, ctxRoot string, tc func(*testing.T, string, http.Handler)) {
|
||||
f, err := os.CreateTemp("", "puzzle15-handler-test")
|
||||
if err != nil {
|
||||
t.Fatalf("temporary file create: %s", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("temporary file close: %s", err)
|
||||
}
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
t.Fatalf("temporary file remove before: %s", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
t.Errorf("temporary file remove after: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
r, err := repo.NewFileRepo(context.Background(), f.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileRepo: %s", err)
|
||||
}
|
||||
err = r.AddUser(userId)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterGameStart: %s", err)
|
||||
}
|
||||
tc(t, strings.TrimRight(ctxRoot, "/"), handler.NewHandler(r, botToken, "1234", ctxRoot, "testdata", "projectLink"))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><title>unit-test-header</title></html>
|
||||
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func StartServer(ctx context.Context, handler http.Handler) (err error) {
|
||||
p, ok := os.LookupEnv("SERVER_PORT")
|
||||
if !ok {
|
||||
p = "8080"
|
||||
}
|
||||
|
||||
slog.Info(fmt.Sprintf("starting server on port: %s", p))
|
||||
go func() {
|
||||
err = http.ListenAndServe(":"+p, handler)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Info("server closed")
|
||||
} else if err != nil {
|
||||
err = fmt.Errorf("listen: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user