package main import ( "log/slog" "net/http" "strings" "time" "git.loyso.art/frx/kurious/assets/kurious" "git.loyso.art/frx/kurious/internal/common/config" "git.loyso.art/frx/kurious/internal/common/generator" "git.loyso.art/frx/kurious/internal/common/xcontext" xhttp "git.loyso.art/frx/kurious/internal/kurious/ports/http" "git.loyso.art/frx/kurious/pkg/xdefault" "github.com/gorilla/mux" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" semconv "go.opentelemetry.io/otel/semconv/v1.25.0" "go.opentelemetry.io/otel/trace" ) func makePathTemplate(params ...string) string { var sb strings.Builder for _, param := range params { sb.Grow(len(param) + 3) sb.WriteRune('/') sb.WriteRune('{') sb.WriteString(param) sb.WriteRune('}') } return sb.String() } func setupCoursesHTTP(srv xhttp.Server, router *mux.Router, _ *slog.Logger) { coursesAPI := srv.Courses() router.Handle("/", http.RedirectHandler("/courses", http.StatusPermanentRedirect)) coursesRouter := router.PathPrefix("/courses").Subrouter().StrictSlash(true) coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam) coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam) muxHandleFunc(coursesRouter, "index", "/", coursesAPI.Index).Methods(http.MethodGet) muxHandleFunc(coursesRouter, "list_learning", coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet) muxHandleFunc(coursesRouter, "list_full", coursesListFullPath, coursesAPI.List).Methods(http.MethodGet) } func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server { router := mux.NewRouter() router.Use( middlewareCustomWriterInjector(), mux.CORSMethodMiddleware(router), middlewareLogger(log), middlewareTrace(), ) setupCoursesHTTP(srv, router, log) if cfg.MountLive { fs := http.FileServer(http.Dir("./assets/kurious/static/")) router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)).Methods(http.MethodGet) registerFile := func(filepath string) { if !strings.HasPrefix(filepath, "/") { filepath = "/" + filepath } relativePath := "./assets/kurious" + filepath router.HandleFunc(filepath, func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, relativePath) }).Methods(http.MethodGet) } for _, file := range []string{ "robots.txt", "android-chrome-192x192.png", "android-chrome-512x512.png", "apple-touch-icon.png", "favicon-16x16.png", "favicon-32x32.png", "favicon.ico", "site.webmanifest", } { registerFile(file) } } else { fs := kurious.AsHTTPFileHandler() router.PathPrefix("/*").Handler(fs).Methods(http.MethodGet) } return &http.Server{ Addr: cfg.ListenAddr, Handler: router, } } func middlewareCustomWriterInjector() mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { wr := wrapWithCustomWriter(w) next.ServeHTTP(wr, r) }) } } type attributeStringKey string func (k attributeStringKey) Value(value string) attribute.KeyValue { return attribute.String(string(k), value) } func middlewareTrace() mux.MiddlewareFunc { reqidAttr := attributeStringKey("http.request_id") return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() reqid := xcontext.GetRequestID(ctx) var span trace.Span route := mux.CurrentRoute(r) hpath, _ := route.GetPathTemplate() ctx, span = webtracer.Start( ctx, r.Method+" "+hpath, trace.WithAttributes( reqidAttr.Value(reqid), semconv.HTTPRequestMethodOriginal(r.Method), semconv.URLFull(hpath), semconv.URLPath(r.URL.Path), semconv.URLQuery(r.URL.RawQuery), semconv.UserAgentOriginal(r.UserAgent()), ), trace.WithSpanKind(trace.SpanKindServer), ) defer span.End() next.ServeHTTP(w, r.WithContext(ctx)) if wr, ok := w.(*customResponseWriter); ok { statusCode := xdefault.WithFallback(wr.statusCode, http.StatusOK) span.SetAttributes( semconv.HTTPResponseStatusCode(statusCode), semconv.HTTPResponseBodySize(wr.wroteBytes), ) if statusCode > 399 { span.SetStatus(codes.Error, "error during request") } else { span.SetStatus(codes.Ok, "request completed") } } }) } } func middlewareLogger(log *slog.Logger) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() requestID := r.Header.Get("x-request-id") if requestID == "" { requestID = generator.RandomInt64ID() } ctx = xcontext.WithLogFields( ctx, slog.String("request_id", requestID), ) ctx = xcontext.WithRequestID(ctx, requestID) xcontext.LogInfo( ctx, log, "incoming request", slog.String("method", r.Method), slog.String("path", r.URL.Path), ) start := time.Now() next.ServeHTTP(w, r.WithContext(ctx)) elapsed := slog.Duration("elapsed", time.Since(start).Truncate(time.Millisecond)) logfields := make([]slog.Attr, 0, 3) logfields = append(logfields, elapsed) if wr, ok := w.(*customResponseWriter); ok { statusCode := xdefault.WithFallback(wr.statusCode, http.StatusOK) logfields = append( logfields, slog.Int("status_code", statusCode), slog.Int("bytes_wrote", wr.wroteBytes), ) } xcontext.LogInfo(ctx, log, "request processed", logfields...) }) } } func wrapWithCustomWriter(origin http.ResponseWriter) *customResponseWriter { return &customResponseWriter{ ResponseWriter: origin, } } type customResponseWriter struct { http.ResponseWriter statusCode int wroteBytes int } func (w *customResponseWriter) WriteHeader(statusCode int) { w.statusCode = statusCode w.ResponseWriter.WriteHeader(statusCode) } func (w *customResponseWriter) Write(data []byte) (n int, err error) { n, err = w.ResponseWriter.Write(data) w.wroteBytes += n return n, err }