|
@@ -0,0 +1,202 @@
|
|
|
+// Package web contains the web-related features: handlers and middleware
|
|
|
+package web
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
+ "log"
|
|
|
+ "net"
|
|
|
+ "net/http"
|
|
|
+ "net/url"
|
|
|
+ "os"
|
|
|
+ "os/signal"
|
|
|
+ "regexp"
|
|
|
+ "strconv"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/gorilla/mux"
|
|
|
+
|
|
|
+ "code.osinet.fr/fgm/lbc/domain"
|
|
|
+ "code.osinet.fr/fgm/lbc/internal"
|
|
|
+)
|
|
|
+
|
|
|
+type (
|
|
|
+ ConfigKeyType string
|
|
|
+ Config struct {
|
|
|
+ internal.Config
|
|
|
+ Addr string
|
|
|
+ Base string // Mount point for service
|
|
|
+ WriteTimeout time.Duration
|
|
|
+ }
|
|
|
+ Server struct {
|
|
|
+ BaseContext context.Context
|
|
|
+
|
|
|
+ *Config
|
|
|
+ *Stats
|
|
|
+ *log.Logger
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+func (c Config) String() string {
|
|
|
+ return fmt.Sprintf("Addr: %s, Base URL: %s, Write Timeout: %v",
|
|
|
+ c.Addr, c.Base, c.WriteTimeout)
|
|
|
+}
|
|
|
+
|
|
|
+const (
|
|
|
+ ConfigKey ConfigKeyType = "config"
|
|
|
+
|
|
|
+ // CT is the normalized representation of the content-type header.
|
|
|
+ CT = "Content-Type"
|
|
|
+
|
|
|
+ // JSON is the normalized representation of the JSON content-type per RFC4627 §6.
|
|
|
+ JSON = "application/json; charset=utf-8"
|
|
|
+
|
|
|
+ PathFB = "/{int1:[0-9]+}/{int2:[0-9]+}/{limit:[0-9]+}/{str1}/{str2}"
|
|
|
+)
|
|
|
+
|
|
|
+var (
|
|
|
+ utf8 = regexp.MustCompile("utf-8")
|
|
|
+ acceptJSON = regexp.MustCompile(`(application/json|\*/\*)`)
|
|
|
+)
|
|
|
+
|
|
|
+func argsFromRequest(r *http.Request) (int1, int2, limit int, str1, str2 string, status int) {
|
|
|
+ var err error
|
|
|
+ v := mux.Vars(r)
|
|
|
+ status = http.StatusAccepted
|
|
|
+ for k, p := range map[string]*int{"int1": &int1, "int2": &int2, "limit": &limit} {
|
|
|
+ sn := v[k] // Cannot fail: route wouldn't match
|
|
|
+ *p, err = strconv.Atoi(sn)
|
|
|
+ if err != nil || *p < 2 {
|
|
|
+ return 0, 0, 0, "", "", http.StatusNotFound
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for k, p := range map[string]*string{"str1": &str1, "str2": &str2} {
|
|
|
+ // Cannot fail: route wouldn't match
|
|
|
+ *p = v[k]
|
|
|
+ }
|
|
|
+ return
|
|
|
+}
|
|
|
+
|
|
|
+func isJSONAccepted(r *http.Request) bool {
|
|
|
+ // Only return to clients accepting UTF-8 or not specifying a charset
|
|
|
+ charset := r.Header.Get("Accept-Charset")
|
|
|
+ if charset != "" && !utf8.MatchString(charset) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ // Only return to clients accepting JSON, anything, or not specifying Accept.
|
|
|
+ accept := r.Header.Get("Accept")
|
|
|
+ return accept == "" || acceptJSON.MatchString(accept)
|
|
|
+}
|
|
|
+
|
|
|
+func MakeFizzBuzz(logger *log.Logger, isVerbose bool) http.HandlerFunc {
|
|
|
+ return func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ // Can we serve client at all?
|
|
|
+ if !isJSONAccepted(r) {
|
|
|
+ if isVerbose {
|
|
|
+ logger.Println("request from client unable to accept JSON")
|
|
|
+ }
|
|
|
+ http.Error(w, "cannot offer an accepted content type: can only serve UTF-8 JSON", http.StatusNotAcceptable)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ int1, int2, limit, str1, str2, status := argsFromRequest(r)
|
|
|
+ if status >= http.StatusBadRequest {
|
|
|
+ logger.Printf("Invalid URL requested: %s", r.URL) // Dev-targeted actual error
|
|
|
+ http.NotFound(w, r) // No human-targeted details to limit enumeration attacks
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if isVerbose {
|
|
|
+ logger.Printf("request: %s", r.URL.Path)
|
|
|
+ }
|
|
|
+ fbs, err := domain.FizzBuzz(int1, int2, limit, str1, str2)
|
|
|
+ if err != nil {
|
|
|
+ logger.Println(err) // Dev-targeted actual error
|
|
|
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // Human-targeted error to user agent
|
|
|
+ return
|
|
|
+ }
|
|
|
+ w.Header().Set(CT, JSON)
|
|
|
+ enc := json.NewEncoder(w)
|
|
|
+ enc.SetEscapeHTML(true) // Prevent XSS on str1/str2
|
|
|
+ enc.SetIndent("", "\t") // Human-readable JSON
|
|
|
+ err = enc.Encode(fbs)
|
|
|
+ if err != nil {
|
|
|
+ logger.Println(err) // Dev-targeted actual error
|
|
|
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // human-targeted error to user agent
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// SetupRouting configures the server route on the specified base path.
|
|
|
+func SetupRouting(base string, logger *log.Logger, stats *Stats, isVerbose bool) (http.Handler, error) {
|
|
|
+ restBase, err := url.JoinPath("/", base) // Perform base validation, including fixing "" as "/".
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed building the base path: %w", err)
|
|
|
+ }
|
|
|
+ sm := http.NewServeMux()
|
|
|
+ router := mux.NewRouter()
|
|
|
+ sub := router.PathPrefix(restBase).
|
|
|
+ Name("base").
|
|
|
+ Subrouter()
|
|
|
+ sub.Handle(PathFB, stats.Middleware()(MakeFizzBuzz(logger, isVerbose))).
|
|
|
+ Name("fizzbuzz")
|
|
|
+ sub.Handle("/stats", stats.Handler())
|
|
|
+ sm.Handle("/", sub)
|
|
|
+ return sm, nil
|
|
|
+}
|
|
|
+
|
|
|
+// Serve starts the HTTP listening loop.
|
|
|
+func (s *Server) Serve(serverClosed chan domain.Empty) error {
|
|
|
+ // Provide access to the globally injected services in handlers.
|
|
|
+ baseContext := func(listener net.Listener) context.Context {
|
|
|
+ return s.BaseContext
|
|
|
+ }
|
|
|
+ r, err := SetupRouting(s.Base, s.Logger, s.Stats, s.Verbose)
|
|
|
+ if err != nil {
|
|
|
+ s.Logger.Fatalf("failed setting up routing: %v", err)
|
|
|
+ }
|
|
|
+ hs := http.Server{
|
|
|
+ Addr: s.Addr,
|
|
|
+ BaseContext: baseContext,
|
|
|
+ ErrorLog: s.Logger,
|
|
|
+ Handler: r,
|
|
|
+ IdleTimeout: 100 * time.Millisecond, // Non-zero to improve benchmarks able to reuse connections.
|
|
|
+ MaxHeaderBytes: 8192, // Cf. Apache as industry-standard practice.
|
|
|
+ ReadHeaderTimeout: 10 * time.Millisecond, // 8kB@1Mbps = 8 msec
|
|
|
+ ReadTimeout: 10 * time.Millisecond, // This server has no method expecting a body, so keep this small
|
|
|
+ TLSConfig: nil,
|
|
|
+ WriteTimeout: s.WriteTimeout, // 1Mbps*100msec = 100kB, which should be sufficient for this API
|
|
|
+ }
|
|
|
+
|
|
|
+ // Setup clean shutdown on signal.
|
|
|
+ go func() {
|
|
|
+ sigs := make(chan os.Signal, 1)
|
|
|
+ signal.Notify(sigs, os.Interrupt)
|
|
|
+ <-sigs
|
|
|
+ if err := hs.Shutdown(context.Background()); err != nil {
|
|
|
+ s.Logger.Printf("web server shutdown: %v", err)
|
|
|
+ }
|
|
|
+ close(serverClosed)
|
|
|
+ }()
|
|
|
+
|
|
|
+ if s.Verbose {
|
|
|
+ s.Logger.Printf("listening with config %s", s.Config)
|
|
|
+ }
|
|
|
+
|
|
|
+ go func() {
|
|
|
+ err = hs.ListenAndServe()
|
|
|
+ switch err {
|
|
|
+ case http.ErrServerClosed:
|
|
|
+ if s.Verbose {
|
|
|
+ s.Logger.Printf("http server shut down cleanly")
|
|
|
+ }
|
|
|
+ err = nil
|
|
|
+ default:
|
|
|
+ s.Logger.Printf("http server unexpected error: %v", err)
|
|
|
+ }
|
|
|
+ }()
|
|
|
+ <-serverClosed
|
|
|
+ return err
|
|
|
+}
|