-
Notifications
You must be signed in to change notification settings - Fork 0
/
servex.go
361 lines (318 loc) · 10.7 KB
/
servex.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
// Package servex provides a basic HTTP(S) server based on a [net/http] and [gorilla/mux].
package servex
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"runtime/debug"
"time"
"github.com/gorilla/mux"
"github.com/maxbolgarin/lang"
)
// Server represents an HTTP server.
type Server struct {
http *http.Server
https *http.Server
router *mux.Router
opts Options
}
// New creates a new instance of the [Server]. You can provide a list of options using With* methods.
// Server without Certificate can serve only plain HTTP.
func New(ops ...Option) *Server {
return NewWithOptions(parseOptions(ops))
}
// NewWithOptions creates a new instance of the [Server] with the provided [Options].
func NewWithOptions(opts Options) *Server {
if opts.Logger == nil {
opts.Logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
if opts.RequestLogger == nil {
opts.RequestLogger = &BaseRequestLogger{opts.Logger}
}
s := &Server{
router: mux.NewRouter(),
opts: opts,
}
s.router.Use(s.loggingMiddleware)
s.router.Use(s.recoverMiddleware)
if s.opts.AuthToken != "" {
s.router.Use(s.authMiddleware)
}
return s
}
// Start starts the server with the provided [BaseConfig] and [Option]s.
// It returns an error if there was an error starting either of the servers.
// You should provide a function that sets the handlers for the server to the router.
// It returns shutdown function so you should shutdown the server manually.
func Start(cfg BaseConfig, handlerSetter func(*mux.Router), opts ...Option) (shutdown func(context.Context) error, err error) {
s, err := prepareServer(cfg, handlerSetter, opts...)
if err != nil {
return nil, err
}
s.Start(cfg.HTTP, cfg.HTTPS)
return s.Shutdown, nil
}
// Start starts the server with the provided [BaseConfig] and [Option]s.
// It returns an error if there was an error starting either of the servers.
// You should provide a function that sets the handlers for the server to the router.
// It shutdowns the server when the context is closed (it starts a goroutine to check [Context.Done]).
func StartWithShutdown(ctx context.Context, cfg BaseConfig, handlerSetter func(*mux.Router), opts ...Option) error {
s, err := prepareServer(cfg, handlerSetter, opts...)
if err != nil {
return err
}
return s.StartWithShutdown(ctx, cfg.HTTP, cfg.HTTPS)
}
// Start starts the server. It takes two parameters: httpAddr and httpsAddr - addresses to listen for HTTP and HTTPS.
// It returns an error if there was an error starting either of the servers.
func (s *Server) Start(httpAddr, httpsAddr string) error {
if httpAddr == "" && httpsAddr == "" {
return errors.New("no address provided")
}
if httpAddr != "" {
if err := s.StartHTTP(httpAddr); err != nil {
return fmt.Errorf("start HTTP server: %w", err)
}
}
if httpsAddr != "" {
if err := s.StartHTTPS(httpsAddr); err != nil {
return fmt.Errorf("start HTTPS server: %w", err)
}
}
return nil
}
// StartHTTP starts an HTTP server on the provided address.
// It returns an error if the server cannot be started or address is invalid.
func (s *Server) StartHTTP(address string) error {
s.http = &http.Server{
Addr: address,
Handler: s.router,
ReadHeaderTimeout: lang.Check(s.opts.ReadHeaderTimeout, defaultReadTimeout),
ReadTimeout: lang.Check(s.opts.ReadTimeout, defaultReadTimeout),
IdleTimeout: lang.Check(s.opts.IdleTimeout, defaultIdleTimeout),
}
if err := s.start(address, s.http.Serve, net.Listen); err != nil {
return err
}
s.opts.Logger.Info("http server started", "address", address)
return nil
}
// StartHTTPS starts an HTTPS server on the provided address.
// It returns an error if the server cannot be started, address is invalid or no certificate is provided in config.
func (s *Server) StartHTTPS(address string) error {
if s.opts.Certificate == nil {
return errors.New("TLS certificate is required for HTTPS server")
}
s.https = &http.Server{
Addr: address,
Handler: s.router,
ReadHeaderTimeout: lang.Check(s.opts.ReadHeaderTimeout, defaultReadTimeout),
ReadTimeout: lang.Check(s.opts.ReadTimeout, defaultReadTimeout),
IdleTimeout: lang.Check(s.opts.IdleTimeout, defaultIdleTimeout),
TLSConfig: getTLSConfig(s.opts.Certificate),
}
if err := s.start(address, s.https.Serve, func(net, addr string) (net.Listener, error) {
return tls.Listen(net, addr, s.https.TLSConfig)
}); err != nil {
return err
}
s.opts.Logger.Info("https server started", "address", address)
return nil
}
// StartWithShutdown starts HTTP and HTTPS servers and shutdowns its when the context is closed.
func (s *Server) StartWithShutdown(ctx context.Context, httpAddr, httpsAddr string) error {
err := s.Start(httpAddr, httpsAddr)
if err != nil {
return err
}
go func() {
<-ctx.Done()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
s.opts.Logger.Error("cannot shutdown", "error", err)
}
}()
return nil
}
// StartWithShutdownHTTP starts the HTTP server and shutdowns it when the context is closed.
func (s *Server) StartWithShutdownHTTP(ctx context.Context, address string) error {
return s.StartWithShutdown(ctx, address, "")
}
// StartWithShutdownHTTPS starts the HTTPS server and shutdowns it when the context is closed.
func (s *Server) StartWithShutdownHTTPS(ctx context.Context, address string) error {
return s.StartWithShutdown(ctx, "", address)
}
// Shutdown gracefully shutdowns HTTP and HTTPS servers.
func (s *Server) Shutdown(ctx context.Context) error {
var errs []error
if s.http != nil {
if err := s.http.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown HTTP: %w", err))
}
}
if s.https != nil {
if err := s.https.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown HTTPS: %w", err))
}
}
return errors.Join(errs...)
}
// HTTPAddress returns the address that HTTP server is listening.
// It returns an empty string if server is not started.
func (s *Server) HTTPAddress() string {
return s.http.Addr
}
// HTTPSAddress returns the address that HTTPS server is listening.
// It returns an empty string if server is not started.
func (s *Server) HTTPSAddress() string {
return s.https.Addr
}
// Router returns [mux.Router], it may be useful if you want to work with router manually.
func (s *Server) Router() *mux.Router {
return s.router
}
// AddMiddleware adds one or more [mux.MiddlewareFunc] to the router.
func (s *Server) AddMiddleware(middleware ...func(http.Handler) http.Handler) {
for _, m := range middleware {
if m == nil {
continue
}
s.router.Use(m)
}
}
// Handle registers a new route with the provided path, [http.Handler] and methods.
// It returns a pointer to the created [mux.Route] to set additional settings to the route.
func (s *Server) Handle(path string, h http.Handler, methods ...string) *mux.Route {
r := s.router.Handle(path, h)
if len(methods) == 0 {
return r
}
return r.Methods(methods...)
}
// Handle registers a new route with the provided path, [http.HandlerFunc] and methods.
// It returns a pointer to the created [mux.Route] to set additional settings to the route.
func (s *Server) HandleFunc(path string, f http.HandlerFunc, methods ...string) *mux.Route {
r := s.router.HandleFunc(path, f)
if len(methods) == 0 {
return r
}
return r.Methods(methods...)
}
func (s *Server) start(address string, serve func(net.Listener) error, getListener func(string, string) (net.Listener, error)) error {
if address == "" {
return errors.New("address is required")
}
l, err := getListener("tcp", address)
if err != nil {
return err
}
go func() {
defer func() {
if r := recover(); r != nil {
s.opts.Logger.Error(string(debug.Stack()), "error", fmt.Errorf("%s", r))
}
}()
if err := serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.opts.Logger.Error("cannot serve", "error", err, "address", address)
}
}()
return nil
}
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if s.opts.Metrics != nil {
s.opts.Metrics.HandleRequest(r)
}
next.ServeHTTP(w, r)
s.logRequest(r, start)
})
}
func (s *Server) recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
panicErr := recover()
if panicErr == nil {
return
}
err := fmt.Errorf("%s", panicErr)
s.opts.Logger.Error(string(debug.Stack()), "error", err)
C(w, r).Error(err, http.StatusInternalServerError, "cannot process request")
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.opts.AuthToken != "" {
if token := r.Header.Get("Authorization"); token != s.opts.AuthToken {
err := errors.New("invalid auth_token=" + token)
C(w, r).Error(err, http.StatusForbidden, "Invalid auth token in Authorization header")
return
}
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logRequest(r *http.Request, start time.Time) {
ctx := r.Context()
noLog, _ := ctx.Value(noLogKey{}).(bool)
if noLog {
return
}
err, _ := ctx.Value(errorKey{}).(error)
msg, _ := ctx.Value(msgKey{}).(string)
code, _ := ctx.Value(codeKey{}).(int)
s.opts.RequestLogger.Log(RequestLogBundle{
Request: r,
RequestID: getOrSetRequestID(r),
Error: err,
ErrorMessage: msg,
StatusCode: code,
StartTime: start,
})
}
func getTLSConfig(cert *tls.Certificate) *tls.Config {
if cert == nil {
return nil
}
return &tls.Config{
Certificates: []tls.Certificate{*cert},
NextProtos: []string{"h2", "http/1.1"}, // enable HTTP2
PreferServerCipherSuites: true,
MinVersion: tls.VersionTLS12, // use only new TLS
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, // only secure ciphers
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
},
CurvePreferences: []tls.CurveID{
tls.CurveP256,
},
}
}
func prepareServer(cfg BaseConfig, handlerSetter func(*mux.Router), opts ...Option) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if cfg.CertFile != "" && cfg.KeyFile != "" {
cert, err := ReadCertificateFromFile(cfg.CertFile, cfg.KeyFile)
if err != nil {
return nil, fmt.Errorf("read certificate: %w", err)
}
opts = append(opts, WithCertificate(cert))
}
if cfg.AuthToken != "" {
opts = append(opts, WithAuthToken(cfg.AuthToken))
}
s := New(opts...)
handlerSetter(s.router)
return s, nil
}