-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathservice.go
617 lines (536 loc) · 17.1 KB
/
service.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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
package main
// URLshortener is a microservice to shorten long URLs
// and to handle the redirection by generated short URLs.
//
// See details in README.md
//
// This file contains service handler interface
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync/atomic"
"time"
_ "embed"
)
const (
// homePage is a simple home page template to display on health check and home requests
homePage = `
<html>
<head>
<title>URL shortener</title>
</head>
<body>
<h1>%s</h1>
<br>
URLshortener %s
<br><br>
Service status: healthy, %d attempts per %d ms
<br><br>
<a href=/ui/generate>Create short URL manually</a>
<br><br><br><br>
See sources at <a href="https://github.com/slytomcat/URLshortener">https://github.com/slytomcat/URLshortener</a>
</body>
</html>`
// generatePage - is a template for short URL generation
generatePage = `
<html>
<head>
<title>Short URL generator</title>
</head>
<body>
<br>
%s
<br>
<form action="/ui/generate" name=f method="GET">
<input maxLength=1024 size=70 name=s value="" title="URL to be shortened">
<input type=submit value="get short URL">
</form>
</body>
</html>`
generatorPagePart = `
<br><br>
Short URL: %s
<br><br>
QR code for short URL:
<br>
<img src="http://quickchart.io/chart?chs=300x300&cht=qr&choe=UTF-8&chl=%s" />
<br>
<br>
Short URL lifetime: %d days
<br>`
)
var (
// favicon is binary image (PNG) that is a response on /favicon.ico request
//go:embed favicon.png
favicon []byte
)
// ServiceHandler interface
type ServiceHandler interface {
ServeHTTP(http.ResponseWriter, *http.Request) // http server handler function
healthCheck() error // Health-check function
start() error // Service start method
stop() // Service stop method
}
// serviceHandler is an instance of ServiceHandler interface
type serviceHandler struct {
tokenDB TokenDB // Database interface
shortToken ShortToken // Short token generator
config *Config // service configuration
server *http.Server // service server
attempts int32 // calculated number of attempts during time-out
}
// ServeHTTP implement simple mux that selects the handler function according to request URL
func (s *serviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Println("access from:", r.RemoteAddr, r.Method, r.RequestURI, r.Header)
switch r.Method + r.URL.Path {
case "GET/":
// request for home page
s.home(w, r)
case "GET/api/v1/healthcheck":
// request for health-check
s.healthcheck(w, r)
case "POST/api/v1/token":
// request for new short url/token
body, err := readBody(r)
if err != nil {
log.Print(err)
w.WriteHeader(http.StatusBadRequest)
return
}
s.new(w, r, body)
case "POST/api/v1/expire":
// request for new short url/token
body, err := readBody(r)
if err != nil {
log.Print(err)
w.WriteHeader(http.StatusBadRequest)
return
}
s.expire(w, r, body)
case "GET/ui/generate":
// UI short URL generation page
s.generate(w, r)
case "GET/favicon.ico":
// WEB-browsers make such requests together with the main request in order to show the site icon on tab header
// In this code it is used for health check (as point to redirect from short url)
w.Write(favicon)
default:
// all the rest GET requests are requests for redirect (probably)
if r.Method == "GET" {
s.redirect(w, r)
} else {
log.Printf("bad method/path: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusBadRequest)
}
}
}
// readBody reads request body and format error
func readBody(r *http.Request) ([]byte, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("request body reading error: %v", err)
}
return body, nil
}
// generate is UI short URL|QR generator
func (s *serviceHandler) generate(w http.ResponseWriter, r *http.Request) {
rMess := fmt.Sprintf("UI generate request from %s (%s)", r.RemoteAddr, r.Referer())
// check that service mode allows this request
if s.config.Mode&disableUI != 0 {
log.Printf("%s: this request is disabled by current service mode\n", rMess)
// send 404 response
http.NotFound(w, r)
return
}
url := r.FormValue("s")
part := ""
if url != "" {
// TO DO: make more sophisticated check for URL
// if URL provided then make short URL for it
sToken, err := s.generateToken(url, s.config.DefaultExp)
if err != nil {
log.Printf("%s: token generation error: %v", rMess, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
sURL := s.config.ShortDomain + "/" + sToken
part = fmt.Sprintf(generatorPagePart, sURL, sURL, s.config.DefaultExp)
rMess = fmt.Sprintf("%s: new token generated: %s", rMess, sToken)
}
// display results
w.Write(fmt.Appendf(nil, generatePage, part))
log.Printf("%s: ui interface displayed", rMess)
}
/* test for test env:
curl -i -v http://localhost:8080/
*/
// Home shows home page
func (s *serviceHandler) home(w http.ResponseWriter, r *http.Request) {
log.Printf("home page request from %s (%s)", r.RemoteAddr, r.Referer())
// show the home page
w.Write(fmt.Appendf(nil,
homePage,
"Home page of URLshortener",
version,
atomic.LoadInt32(&s.attempts),
s.config.Timeout))
}
/* test for test env:
curl -i -v http://localhost:8080/healthcheck
*/
// healthcheck also shows home page if self-check successfully passed
func (s *serviceHandler) healthcheck(w http.ResponseWriter, r *http.Request) {
rMess := fmt.Sprintf("health-check request from %s (%s)", r.RemoteAddr, r.Referer())
// Perform self-test
if err := s.healthCheck(); err != nil {
// report error
log.Printf("%s: error: %v\n", rMess, err)
w.WriteHeader(http.StatusInternalServerError)
} else {
// log self-test results
log.Printf("%s: success\n", rMess)
// show the home page if self-test was successfully passed
w.Write(fmt.Appendf(nil,
homePage,
"Health check page",
version,
atomic.LoadInt32(&s.attempts),
s.config.Timeout))
}
}
// healthCheck performs full self-test of service in all service modes
func (s *serviceHandler) healthCheck() error {
// self-test makes three requests:
// 1. request for short URL
// 2. request for redirect from short to long URL
// 3. request to expire the token (received in the first request)
// long URL for sef-check redirect
url := s.config.ShortDomain + "/favicon.ico"
var (
// short URL request's replay parameters
repl struct {
URL string `json:"url"`
Token string `json:"token"`
}
err error
)
// self-test part 1: get short URL
if s.config.Mode&disableShortener != 0 {
// use tokenDB interface as web-interface is locked in this service mode
sToken, err := s.generateToken(url, 1)
if err != nil {
return fmt.Errorf("new token creation error: %w", err)
}
// store results
repl.Token = sToken
repl.URL = s.config.ShortDomain + "/" + repl.Token
} else {
// make the HTTP request for new token
resp, err := http.Post("http://"+s.config.ListenHostPort+"/api/v1/token", "application/json",
strings.NewReader(`{"url": "`+url+`","exp": 1}`))
if err != nil {
return fmt.Errorf("new token request error: %w", err)
}
defer resp.Body.Close()
// check response status code
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("new token request: unexpected response status: %v", resp.StatusCode)
}
// read response body
buf, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("new token response body reading error : %w", err)
}
// parse response body
if err = json.Unmarshal(buf, &repl); err != nil {
return fmt.Errorf("new token response body parsing error: %w", err)
}
// check received token
if repl.Token == "" {
return errors.New("empty token returned")
}
}
// self-test part 2: check redirect
rURL := "" // variable to store redirect URL
if s.config.Mode&disableRedirect != 0 {
// use tokenDB interface as web-interface is locked in this service mode
rURL, err = s.tokenDB.Get(repl.Token)
if err != nil {
return fmt.Errorf("URL receiving error: %w", err)
}
} else {
// try to make the HTTP request for redirect by short URL
resp2, err := http.Get("http://" + repl.URL)
if err != nil {
return fmt.Errorf("redirect request error: %w", err)
}
defer resp2.Body.Close()
// check redirect response status
if resp2.StatusCode != http.StatusOK {
return fmt.Errorf("redirect request: unexpected response status: %v", resp2.StatusCode)
}
// get redirection URL
rURL = resp2.Request.URL.String()
}
// check redirection URL
if rURL != "http://"+url {
return fmt.Errorf("wrong redirection URL: expected %s, received %v", url, rURL)
}
// self-test part 3: make received token as expired
if s.config.Mode&disableExpire != 0 {
// use tokenDB interface as web-interface is locked in this service mode
if err := s.tokenDB.Expire(repl.Token, -1); err != nil {
return fmt.Errorf("expire request error: %w", err)
}
} else {
// make the HTTP request to expire token
resp3, err := http.Post("http://"+s.config.ListenHostPort+"/api/v1/expire", "application/json",
strings.NewReader(`{"token": "`+repl.Token+`","exp":-1}`))
if err != nil {
return fmt.Errorf("expire request error: %w", err)
}
defer resp3.Body.Close()
// check response status
if resp3.StatusCode != http.StatusOK {
return fmt.Errorf("expire request: unexpected response status: %v", resp3.StatusCode)
}
}
return nil
}
/* test for test env:
curl -i -v http://localhost:8080/<token>
*/
// Redirect handles redirection to URL that was stored for the specified token
func (s *serviceHandler) redirect(w http.ResponseWriter, r *http.Request) {
sToken := r.URL.Path[1:] // GET and POST always contain at least "/" in URL
rMess := fmt.Sprintf("redirect request from %s (%s), token: %s", r.RemoteAddr, r.Referer(), sToken)
// check that service mode allows this request
if s.config.Mode&disableRedirect != 0 {
log.Printf("%s: this request is disabled by current service mode\n", rMess)
// send 404 response
http.NotFound(w, r)
return
}
// check the token
if err := s.validateToken(sToken); err != nil {
log.Printf("%s: incorrect token: %v\n", rMess, err)
http.NotFound(w, r)
return
}
// get the long URL
longURL, err := s.tokenDB.Get(sToken)
if err != nil {
log.Printf("%s: token was not found\n", rMess)
// send 404 response
http.NotFound(w, r)
return
}
// log the request results
log.Printf("%s: redirected to %s\n", rMess, longURL)
// respond by redirect
http.Redirect(w, r, longURL, http.StatusFound)
}
func (s *serviceHandler) validateToken(t string) error {
// check the token length
if s.config.Mode&disableLengthCheck == 0 {
if err := s.shortToken.CheckLength(t); err != nil {
return err
}
}
return s.shortToken.CheckAlphabet(t)
}
/* test for test env:
curl -v POST -H "Content-Type: application/json" -d '{"url":"<long url>","exp":10}' http://localhost:8080/api/v1/token
*/
// new handle the new token creation for passed url and sets expiration for it
func (s *serviceHandler) new(w http.ResponseWriter, r *http.Request, body []byte) {
// TODO: check some authorization ???
rMess := fmt.Sprintf("token request from %s (%s)", r.RemoteAddr, r.Referer())
// Check that service mode allows this request
if s.config.Mode&disableShortener != 0 {
log.Printf("%s: this request is disabled by current service mode\n", rMess)
// request is not supported: send 404 response
http.NotFound(w, r)
return
}
// the request parameters structure
var params struct {
URL string `json:"url"` // long URL
Exp int `json:"exp,omitempty"` // Expiration
}
// parse body to parameters structure
err := json.Unmarshal(body, ¶ms)
if err != nil || params.URL == "" {
log.Printf("%s: bad request parameters:%s", rMess, body)
w.WriteHeader(http.StatusBadRequest)
return
}
// log received params
rMess += fmt.Sprintf(" parameters: '%s', %d", params.URL, params.Exp)
sToken, err := s.generateToken(params.URL, params.Exp)
// handle token generation error
if err != nil {
log.Printf("%s: token generation error:%s", rMess, body)
if strings.Contains(err.Error(), "creation error") {
w.WriteHeader(http.StatusRequestTimeout)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
// make response body
resp, _ := json.Marshal(
struct {
Token string `json:"token"` // token
URL string `json:"url"` // short URL
}{
Token: sToken,
URL: s.config.ShortDomain + "/" + sToken,
})
// log new token request information
log.Printf("%s: URL saved, token: %s , exp: %d\n", rMess, sToken, params.Exp)
// send response
w.Write(resp)
}
// generateToken generates token or writes the error in w
func (s *serviceHandler) generateToken(url string, exp int) (string, error) {
// Using many attempts to store the new random token dramatically increases maximum amount of
// used tokens since:
// probability of the failure of n attempts = (probability of failure of single attempt)^n.
// Limit number of attempts by time not by count
// Count attempts and time for reports
var attempt int64
var startTime time.Time
var err error
// add reference type if it is missing
if !strings.HasPrefix(strings.ToLower(url), "http") {
url = "http://" + url
}
// set the default expiration if it is not passed
if exp == 0 {
exp = s.config.DefaultExp
}
// Calculate statistics and report if some dangerous situation appears
defer func() {
elapsedTime := time.Since(startTime)
// perform statistical calculation and reporting in another go-routine
go func() {
if attempt > 0 {
MaxAtt := attempt * int64(s.config.Timeout) * 1000000 / elapsedTime.Nanoseconds()
// use atomic to avoid race conditions
atomic.StoreInt32(&s.attempts, int32(MaxAtt))
// report warnings of some not good measurements
if MaxAtt*3/4 < attempt {
log.Printf("Warning: Measured %d attempts for %d ns. Calculated %d max attempts per %d ms\n", attempt, elapsedTime, MaxAtt, s.config.Timeout)
}
if MaxAtt > 0 && MaxAtt < 10 {
log.Printf("Warning: Too low number of attempts: %d per timeout (%d ms)\n", MaxAtt, s.config.Timeout)
}
}
}()
}()
sToken := ""
// make time-out chanel
stop := time.After(time.Millisecond * time.Duration(s.config.Timeout))
// Remember starting time
startTime = time.Now()
// start trying to store new token
for ok := false; !ok; {
select {
case <-stop:
// timeout exceeded
return "", fmt.Errorf("token creation error: %v, ok: %v", err, ok)
default:
// get short token
sToken = s.shortToken.Get()
// count attempts
attempt++
// store token in DB
ok, err = s.tokenDB.Set(sToken, url, exp)
if err != nil {
return "", fmt.Errorf("token storing error: %v", err)
}
}
}
return sToken, nil
}
/* test for test env:
curl -v POST -H "Content-Type: application/json" -d '{"token":"<token>","exp":<exp>}' http://localhost:8080/api/v1/expire
*/
// expire makes token-longURL record as expired
func (s *serviceHandler) expire(w http.ResponseWriter, r *http.Request, body []byte) {
// TODO: check some authorization ???
rMess := fmt.Sprintf("expire request from %s (%s)", r.RemoteAddr, r.Referer())
// Check that service mode allows this request
if s.config.Mode&disableExpire != 0 {
log.Printf("%s: this request is disabled by current service mode\n", rMess)
// request is not supported: send 404 response
http.NotFound(w, r)
return
}
// make the request parameters structure
var params struct {
Token string `json:"token"` // Token of short URL token
Exp int `json:"exp,omitempty"` // Expiration
}
// parse JSON from body to parameters structure
err := json.Unmarshal(body, ¶ms)
if err != nil || params.Token == "" {
log.Printf("%s: bad request parameters:%s", rMess, body)
w.WriteHeader(http.StatusBadRequest)
return
}
if err := s.validateToken(params.Token); err != nil {
log.Printf("%s: incorrect token: %v\n", rMess, err)
http.NotFound(w, r)
return
}
// update token expiration
err = s.tokenDB.Expire(params.Token, params.Exp)
if err != nil {
log.Printf("%s: updating token expiration error: %s", rMess, err)
w.WriteHeader(http.StatusNotModified)
return
}
// log request results
log.Printf("%s: token expiration of %s has set to %d\n", rMess, params.Token, params.Exp)
// send response
w.WriteHeader(http.StatusOK)
}
// Start returns started server
func (s *serviceHandler) start() error {
log.Println("starting server at", s.config.ListenHostPort)
return s.server.ListenAndServe()
}
// Stop performs graceful shutdown of server and database interfaces
func (s *serviceHandler) stop() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s.server.Shutdown(ctx)
if err != nil {
log.Printf("HTTP server shutdown error: %v", err)
}
}
// NewHandler returns new service handler
func NewHandler(config *Config, tokenDB TokenDB, shortToken ShortToken) ServiceHandler {
// make handler
handler := &serviceHandler{
tokenDB: tokenDB,
shortToken: shortToken,
config: config,
server: nil,
attempts: 0,
}
// create server
handler.server = &http.Server{
Addr: config.ListenHostPort,
Handler: handler,
}
return handler
}