move server to server directory.

This commit is contained in:
Miroslav Šedivý
2024-06-23 17:48:14 +02:00
parent da45f62ca8
commit 5b98344205
211 changed files with 18 additions and 10 deletions

View File

@ -0,0 +1,123 @@
package http
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type BatchRequest struct {
Path string `json:"path"`
Method string `json:"method"`
Body json.RawMessage `json:"body,omitempty"`
}
type BatchResponse struct {
Path string `json:"path"`
Method string `json:"method"`
Body json.RawMessage `json:"body,omitempty"`
Status int `json:"status"`
}
func (b *BatchResponse) Error(httpErr *utils.HTTPError) (err error) {
b.Body, err = json.Marshal(httpErr)
b.Status = httpErr.Code
return
}
type batchHandler struct {
Router types.Router
PathPrefix string
Excluded []string
}
func (b *batchHandler) Handle(w http.ResponseWriter, r *http.Request) error {
var requests []BatchRequest
if err := json.NewDecoder(r.Body).Decode(&requests); err != nil {
return err
}
responses := make([]BatchResponse, len(requests))
for i, request := range requests {
res := BatchResponse{
Path: request.Path,
Method: request.Method,
}
if !strings.HasPrefix(request.Path, b.PathPrefix) {
res.Error(utils.HttpBadRequest("this path is not allowed in batch requests"))
responses[i] = res
continue
}
if exists, _ := utils.ArrayIn(request.Path, b.Excluded); exists {
res.Error(utils.HttpBadRequest("this path is excluded from batch requests"))
responses[i] = res
continue
}
// prepare request
req, err := http.NewRequest(request.Method, request.Path, bytes.NewBuffer(request.Body))
if err != nil {
return err
}
// copy headers
for k, vv := range r.Header {
for _, v := range vv {
req.Header.Add(k, v)
}
}
// execute request
rr := newResponseRecorder()
b.Router.ServeHTTP(rr, req)
// read response
body, err := io.ReadAll(rr.Body)
if err != nil {
return err
}
// write response
responses[i] = BatchResponse{
Path: request.Path,
Method: request.Method,
Body: body,
Status: rr.Code,
}
}
return utils.HttpSuccess(w, responses)
}
type responseRecorder struct {
Code int
HeaderMap http.Header
Body *bytes.Buffer
}
func newResponseRecorder() *responseRecorder {
return &responseRecorder{
Code: http.StatusOK,
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}
}
func (w *responseRecorder) Header() http.Header {
return w.HeaderMap
}
func (w *responseRecorder) Write(b []byte) (int, error) {
return w.Body.Write(b)
}
func (w *responseRecorder) WriteHeader(code int) {
w.Code = code
}

View File

@ -0,0 +1,36 @@
package http
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi"
"github.com/demodesk/neko/pkg/types"
)
func pprofHandler(r types.Router) {
r.Get("/debug/pprof/", func(w http.ResponseWriter, r *http.Request) error {
pprof.Index(w, r)
return nil
})
r.Get("/debug/pprof/{action}", func(w http.ResponseWriter, r *http.Request) error {
action := chi.URLParam(r, "action")
switch action {
case "cmdline":
pprof.Cmdline(w, r)
case "profile":
pprof.Profile(w, r)
case "symbol":
pprof.Symbol(w, r)
case "trace":
pprof.Trace(w, r)
default:
pprof.Handler(action).ServeHTTP(w, r)
}
return nil
})
}

View File

@ -0,0 +1,135 @@
package http
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type logFormatter struct {
logger zerolog.Logger
}
func (l *logFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
// exclude health & metrics from logs
if r.RequestURI == "/health" || r.RequestURI == "/metrics" {
return &nulllog{}
}
req := map[string]any{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
req["id"] = reqID
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
req["scheme"] = scheme
req["proto"] = r.Proto
req["method"] = r.Method
req["remote"] = r.RemoteAddr
req["agent"] = r.UserAgent()
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
return &logEntry{
logger: l.logger.With().Interface("req", req).Logger(),
}
}
type logEntry struct {
logger zerolog.Logger
err error
panic *logPanic
session types.Session
}
type logPanic struct {
message string
stack string
}
func (e *logEntry) Panic(v any, stack []byte) {
e.panic = &logPanic{
message: fmt.Sprintf("%+v", v),
stack: string(stack),
}
}
func (e *logEntry) Error(err error) {
e.err = err
}
func (e *logEntry) SetSession(session types.Session) {
e.session = session
}
func (e *logEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
res := map[string]any{}
res["time"] = time.Now().UTC().Format(time.RFC1123)
res["status"] = status
res["bytes"] = bytes
res["elapsed"] = float64(elapsed.Nanoseconds()) / 1000000.0
logger := e.logger.With().Interface("res", res).Logger()
// add session ID to logs (if exists)
if e.session != nil {
logger = logger.With().Str("session_id", e.session.ID()).Logger()
}
// handle panic error message
if e.panic != nil {
logger.WithLevel(zerolog.PanicLevel).
Err(e.err).
Str("stack", e.panic.stack).
Msgf("request failed (%d): %s", status, e.panic.message)
return
}
// handle panic error message
if e.err != nil {
httpErr, ok := e.err.(*utils.HTTPError)
if !ok {
logger.Err(e.err).Msgf("request failed (%d)", status)
return
}
if httpErr.Message == "" {
httpErr.Message = http.StatusText(httpErr.Code)
}
var logLevel zerolog.Level
if httpErr.Code < 500 {
logLevel = zerolog.WarnLevel
} else {
logLevel = zerolog.ErrorLevel
}
message := httpErr.Message
if httpErr.InternalMsg != "" {
message = httpErr.InternalMsg
}
logger.WithLevel(logLevel).Err(httpErr.InternalErr).Msgf("request failed (%d): %s", status, message)
return
}
logger.Debug().Msgf("request complete (%d)", status)
}
type nulllog struct{}
func (e *nulllog) Panic(v any, stack []byte) {}
func (e *nulllog) Error(err error) {}
func (e *nulllog) SetSession(session types.Session) {}
func (e *nulllog) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
}

View File

@ -0,0 +1,132 @@
package http
import (
"context"
"net/http"
"os"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/demodesk/neko/internal/config"
"github.com/demodesk/neko/pkg/types"
)
type HttpManagerCtx struct {
logger zerolog.Logger
config *config.Server
router types.Router
http *http.Server
}
func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server) *HttpManagerCtx {
logger := log.With().Str("module", "http").Logger()
opts := []RouterOption{
WithRequestID(), // create a request id for each request
}
// use real ip if behind proxy
// before logger so it can log the real ip
if config.Proxy {
opts = append(opts, WithRealIP())
}
opts = append(opts,
WithLogger(logger),
WithRecoverer(), // recover from panics without crashing server
)
if config.HasCors() {
opts = append(opts, WithCORS(config.AllowOrigin))
}
if config.PathPrefix != "/" {
opts = append(opts, WithPathPrefix(config.PathPrefix))
}
router := newRouter(opts...)
router.Route("/api", ApiManager.Route)
router.Get("/api/ws", WebSocketManager.Upgrade(func(r *http.Request) bool {
return config.AllowOrigin(r.Header.Get("Origin"))
}))
batch := batchHandler{
Router: router,
PathPrefix: "/api",
Excluded: []string{
"/api/batch", // do not allow batchception
"/api/ws",
},
}
router.Post("/api/batch", batch.Handle)
router.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("true"))
return err
})
if config.Metrics {
router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) error {
promhttp.Handler().ServeHTTP(w, r)
return nil
})
}
if config.Static != "" {
fs := http.FileServer(http.Dir(config.Static))
router.Get("/*", func(w http.ResponseWriter, r *http.Request) error {
_, err := os.Stat(config.Static + r.URL.Path)
if err == nil {
fs.ServeHTTP(w, r)
return nil
}
if os.IsNotExist(err) {
http.NotFound(w, r)
return nil
}
return err
})
}
if config.PProf {
pprofHandler(router)
}
return &HttpManagerCtx{
logger: logger,
config: config,
router: router,
http: &http.Server{
Addr: config.Bind,
Handler: router,
},
}
}
func (manager *HttpManagerCtx) Start() {
if manager.config.Cert != "" && manager.config.Key != "" {
go func() {
if err := manager.http.ListenAndServeTLS(manager.config.Cert, manager.config.Key); err != http.ErrServerClosed {
manager.logger.Panic().Err(err).Msg("unable to start https server")
}
}()
manager.logger.Info().Msgf("https listening on %s", manager.http.Addr)
} else {
go func() {
if err := manager.http.ListenAndServe(); err != http.ErrServerClosed {
manager.logger.Panic().Err(err).Msg("unable to start http server")
}
}()
manager.logger.Info().Msgf("http listening on %s", manager.http.Addr)
}
}
func (manager *HttpManagerCtx) Shutdown() error {
manager.logger.Info().Msg("shutdown")
return manager.http.Shutdown(context.Background())
}

View File

@ -0,0 +1,172 @@
package http
import (
"net/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/rs/zerolog"
"github.com/demodesk/neko/pkg/auth"
"github.com/demodesk/neko/pkg/types"
"github.com/demodesk/neko/pkg/utils"
)
type RouterOption func(*router)
func WithRequestID() RouterOption {
return func(r *router) {
r.chi.Use(middleware.RequestID)
}
}
func WithLogger(logger zerolog.Logger) RouterOption {
return func(r *router) {
r.chi.Use(middleware.RequestLogger(&logFormatter{logger}))
}
}
func WithRecoverer() RouterOption {
return func(r *router) {
r.chi.Use(middleware.Recoverer)
}
}
func WithCORS(allowOrigin func(origin string) bool) RouterOption {
return func(r *router) {
r.chi.Use(cors.Handler(cors.Options{
AllowOriginFunc: func(r *http.Request, origin string) bool {
return allowOrigin(origin)
},
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
}
}
func WithPathPrefix(prefix string) RouterOption {
return func(r *router) {
r.chi.Use(func(h http.Handler) http.Handler {
return http.StripPrefix(prefix, h)
})
}
}
func WithRealIP() RouterOption {
return func(r *router) {
r.chi.Use(middleware.RealIP)
}
}
type router struct {
chi chi.Router
}
func newRouter(opts ...RouterOption) types.Router {
r := &router{chi.NewRouter()}
for _, opt := range opts {
opt(r)
}
return r
}
func (r *router) Group(fn func(types.Router)) {
r.chi.Group(func(c chi.Router) {
fn(&router{c})
})
}
func (r *router) Route(pattern string, fn func(types.Router)) {
r.chi.Route(pattern, func(c chi.Router) {
fn(&router{c})
})
}
func (r *router) Get(pattern string, fn types.RouterHandler) {
r.chi.Get(pattern, routeHandler(fn))
}
func (r *router) Post(pattern string, fn types.RouterHandler) {
r.chi.Post(pattern, routeHandler(fn))
}
func (r *router) Put(pattern string, fn types.RouterHandler) {
r.chi.Put(pattern, routeHandler(fn))
}
func (r *router) Patch(pattern string, fn types.RouterHandler) {
r.chi.Patch(pattern, routeHandler(fn))
}
func (r *router) Delete(pattern string, fn types.RouterHandler) {
r.chi.Delete(pattern, routeHandler(fn))
}
func (r *router) With(fn types.MiddlewareHandler) types.Router {
c := r.chi.With(middlewareHandler(fn))
return &router{c}
}
func (r *router) Use(fn types.MiddlewareHandler) {
r.chi.Use(middlewareHandler(fn))
}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.chi.ServeHTTP(w, req)
}
func errorHandler(err error, w http.ResponseWriter, r *http.Request) {
httpErr, ok := err.(*utils.HTTPError)
if !ok {
httpErr = utils.HttpInternalServerError().WithInternalErr(err)
}
utils.HttpJsonResponse(w, httpErr.Code, httpErr)
}
func routeHandler(fn types.RouterHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// get custom log entry pointer from context
logEntry, _ := r.Context().Value(middleware.LogEntryCtxKey).(*logEntry)
if err := fn(w, r); err != nil {
logEntry.Error(err)
errorHandler(err, w, r)
}
// set session if exits
if session, ok := auth.GetSession(r); ok {
logEntry.SetSession(session)
}
}
}
func middlewareHandler(fn types.MiddlewareHandler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// get custom log entry pointer from context
logEntry, _ := r.Context().Value(middleware.LogEntryCtxKey).(*logEntry)
ctx, err := fn(w, r)
if err != nil {
logEntry.Error(err)
errorHandler(err, w, r)
// set session if exits
if session, ok := auth.GetSession(r); ok {
logEntry.SetSession(session)
}
return
}
if ctx != nil {
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}