This commit is contained in:
Ilia Denisov
2025-09-03 18:43:28 +03:00
commit 31957206b8
40 changed files with 2947 additions and 0 deletions
+182
View File
@@ -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"))
}
+1
View File
@@ -0,0 +1 @@
<html><title>unit-test-header</title></html>
+30
View File
@@ -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
}