serve.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. // Package web contains the web-related features: handlers and middleware
  2. package web
  3. import (
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "log"
  8. "net"
  9. "net/http"
  10. "net/url"
  11. "os"
  12. "os/signal"
  13. "regexp"
  14. "strconv"
  15. "time"
  16. "github.com/gorilla/mux"
  17. "code.osinet.fr/fgm/lbc/domain"
  18. "code.osinet.fr/fgm/lbc/internal"
  19. )
  20. type (
  21. ConfigKeyType string
  22. Config struct {
  23. internal.Config
  24. Addr string
  25. Base string // Mount point for service
  26. WriteTimeout time.Duration
  27. }
  28. Server struct {
  29. BaseContext context.Context
  30. *Config
  31. *Stats
  32. *log.Logger
  33. }
  34. )
  35. func (c Config) String() string {
  36. return fmt.Sprintf("Addr: %s, Base URL: %s, Write Timeout: %v",
  37. c.Addr, c.Base, c.WriteTimeout)
  38. }
  39. const (
  40. ConfigKey ConfigKeyType = "config"
  41. // CT is the normalized representation of the content-type header.
  42. CT = "Content-Type"
  43. // JSON is the normalized representation of the JSON content-type per RFC4627 §6.
  44. JSON = "application/json; charset=utf-8"
  45. PathFB = "/{int1:[0-9]+}/{int2:[0-9]+}/{limit:[0-9]+}/{str1}/{str2}"
  46. )
  47. var (
  48. utf8 = regexp.MustCompile("utf-8")
  49. acceptJSON = regexp.MustCompile(`(application/json|\*/\*)`)
  50. )
  51. func argsFromRequest(r *http.Request) (int1, int2, limit int, str1, str2 string, status int) {
  52. var err error
  53. v := mux.Vars(r)
  54. status = http.StatusAccepted
  55. for k, p := range map[string]*int{"int1": &int1, "int2": &int2, "limit": &limit} {
  56. sn := v[k] // Cannot fail: route wouldn't match
  57. *p, err = strconv.Atoi(sn)
  58. if err != nil || *p < 2 {
  59. return 0, 0, 0, "", "", http.StatusNotFound
  60. }
  61. }
  62. for k, p := range map[string]*string{"str1": &str1, "str2": &str2} {
  63. // Cannot fail: route wouldn't match
  64. *p = v[k]
  65. }
  66. return
  67. }
  68. func isJSONAccepted(r *http.Request) bool {
  69. // Only return to clients accepting UTF-8 or not specifying a charset
  70. charset := r.Header.Get("Accept-Charset")
  71. if charset != "" && !utf8.MatchString(charset) {
  72. return false
  73. }
  74. // Only return to clients accepting JSON, anything, or not specifying Accept.
  75. accept := r.Header.Get("Accept")
  76. return accept == "" || acceptJSON.MatchString(accept)
  77. }
  78. func MakeFizzBuzz(logger *log.Logger, isVerbose bool) http.HandlerFunc {
  79. return func(w http.ResponseWriter, r *http.Request) {
  80. // Can we serve client at all?
  81. if !isJSONAccepted(r) {
  82. if isVerbose {
  83. logger.Println("request from client unable to accept JSON")
  84. }
  85. http.Error(w, "cannot offer an accepted content type: can only serve UTF-8 JSON", http.StatusNotAcceptable)
  86. return
  87. }
  88. int1, int2, limit, str1, str2, status := argsFromRequest(r)
  89. if status >= http.StatusBadRequest {
  90. logger.Printf("Invalid URL requested: %s", r.URL) // Dev-targeted actual error
  91. http.NotFound(w, r) // No human-targeted details to limit enumeration attacks
  92. return
  93. }
  94. if isVerbose {
  95. logger.Printf("request: %s", r.URL.Path)
  96. }
  97. fbs, err := domain.FizzBuzz(int1, int2, limit, str1, str2)
  98. if err != nil {
  99. logger.Println(err) // Dev-targeted actual error
  100. http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // Human-targeted error to user agent
  101. return
  102. }
  103. w.Header().Set(CT, JSON)
  104. enc := json.NewEncoder(w)
  105. enc.SetEscapeHTML(true) // Prevent XSS on str1/str2
  106. enc.SetIndent("", "\t") // Human-readable JSON
  107. err = enc.Encode(fbs)
  108. if err != nil {
  109. logger.Println(err) // Dev-targeted actual error
  110. http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // human-targeted error to user agent
  111. return
  112. }
  113. }
  114. }
  115. // SetupRouting configures the server route on the specified base path.
  116. func SetupRouting(base string, logger *log.Logger, stats *Stats, isVerbose bool) (http.Handler, error) {
  117. restBase, err := url.JoinPath("/", base) // Perform base validation, including fixing "" as "/".
  118. if err != nil {
  119. return nil, fmt.Errorf("failed building the base path: %w", err)
  120. }
  121. sm := http.NewServeMux()
  122. router := mux.NewRouter()
  123. sub := router.PathPrefix(restBase).
  124. Name("base").
  125. Subrouter()
  126. sub.Handle(PathFB, stats.Middleware()(MakeFizzBuzz(logger, isVerbose))).
  127. Name("fizzbuzz")
  128. sub.Handle("/stats", stats.Handler())
  129. sm.Handle("/", sub)
  130. return sm, nil
  131. }
  132. // Serve starts the HTTP listening loop.
  133. func (s *Server) Serve(serverClosed chan domain.Empty) error {
  134. // Provide access to the globally injected services in handlers.
  135. baseContext := func(listener net.Listener) context.Context {
  136. return s.BaseContext
  137. }
  138. r, err := SetupRouting(s.Base, s.Logger, s.Stats, s.Verbose)
  139. if err != nil {
  140. s.Logger.Fatalf("failed setting up routing: %v", err)
  141. }
  142. hs := http.Server{
  143. Addr: s.Addr,
  144. BaseContext: baseContext,
  145. ErrorLog: s.Logger,
  146. Handler: r,
  147. IdleTimeout: 100 * time.Millisecond, // Non-zero to improve benchmarks able to reuse connections.
  148. MaxHeaderBytes: 8192, // Cf. Apache as industry-standard practice.
  149. ReadHeaderTimeout: 10 * time.Millisecond, // 8kB@1Mbps = 8 msec
  150. ReadTimeout: 10 * time.Millisecond, // This server has no method expecting a body, so keep this small
  151. TLSConfig: nil,
  152. WriteTimeout: s.WriteTimeout, // 1Mbps*100msec = 100kB, which should be sufficient for this API
  153. }
  154. // Setup clean shutdown on signal.
  155. go func() {
  156. sigs := make(chan os.Signal, 1)
  157. signal.Notify(sigs, os.Interrupt)
  158. <-sigs
  159. if err := hs.Shutdown(context.Background()); err != nil {
  160. s.Logger.Printf("web server shutdown: %v", err)
  161. }
  162. close(serverClosed)
  163. }()
  164. if s.Verbose {
  165. s.Logger.Printf("listening with config %s", s.Config)
  166. }
  167. go func() {
  168. err = hs.ListenAndServe()
  169. switch err {
  170. case http.ErrServerClosed:
  171. if s.Verbose {
  172. s.Logger.Printf("http server shut down cleanly")
  173. }
  174. err = nil
  175. default:
  176. s.Logger.Printf("http server unexpected error: %v", err)
  177. }
  178. }()
  179. <-serverClosed
  180. return err
  181. }