diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 52b06b0..462bdd1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,9 +2,9 @@ name: go build on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: @@ -32,7 +32,7 @@ jobs: run: go build -v ./... - name: Lint - uses: golangci/golangci-lint-action@v3.3.1 + uses: golangci/golangci-lint-action@v6 with: version: latest args: --timeout 5m diff --git a/README.md b/README.md index b413ad6..7615727 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # qr-coder +[![codecov](https://codecov.io/gh/etilite/qr-coder/graph/badge.svg?token=A70ZRV50JV)](https://codecov.io/gh/etilite/qr-coder) + Microservice to generate QR-codes in png format. -Text content is encoded in UTF-8. + +By default, `qr-coder` adds to content `UTF-8 BOM` prefix for better unicode-compatibility with scanners. ## Usage ### Build project diff --git a/cmd/qr-coder/main.go b/cmd/qr-coder/main.go index 7e9d589..74b972d 100644 --- a/cmd/qr-coder/main.go +++ b/cmd/qr-coder/main.go @@ -1,63 +1,25 @@ package main import ( + "context" "log/slog" - "os" "os/signal" "syscall" - "time" "github.com/etilite/qr-coder/internal/app" httpserver "github.com/etilite/qr-coder/internal/delivery/http" ) -const ( - shutdownTime time.Duration = 10 * time.Second -) - func main() { - cfg := app.NewConfigFromEnv() + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() - a := app.New(cfg) + cfg := app.NewConfigFromEnv() server := httpserver.NewServer(cfg.HTTPAddr) - if err := a.Run(server); err != nil { + if err := server.Run(ctx); err != nil { slog.Error("unable to start app", "error", err) panic(err) } - - shutdown := make(chan bool) - gracefulStop(shutdown) - <-shutdown - slog.Info("signal caught, shutting down") - - go aggressiveStop() - - if err := a.Stop(); err != nil { - slog.Error("unable to stop app", "error", err) - panic(err) - } -} - -func gracefulStop(shutdown chan<- bool) { - c := make(chan os.Signal) - signal.Notify(c, - syscall.SIGHUP, - syscall.SIGINT, - syscall.SIGTERM, - syscall.SIGQUIT) - - go func() { - <-c - shutdown <- true - }() -} - -func aggressiveStop() { - ticker := time.NewTicker(shutdownTime) - <-ticker.C - - slog.Warn("application is aggressive stopped") - os.Exit(0) } diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index cbb6ced..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,44 +0,0 @@ -package app - -import ( - "log/slog" -) - -type service interface { - Run() error - Stop() error -} - -type App struct { - logger *slog.Logger - services []service -} - -func New(cfg Config) *App { - return &App{ - logger: slog.Default(), - } -} - -func (app *App) Run(services ...service) error { - for _, srv := range services { - if err := srv.Run(); err != nil { - return err - } - app.services = append(app.services, srv) - } - app.logger.Info("app started") - - return nil -} - -func (app *App) Stop() error { - for _, srv := range app.services { - if err := srv.Stop(); err != nil { - return err - } - } - app.logger.Info("app stopped") - - return nil -} diff --git a/internal/coder/coder.go b/internal/coder/coder.go index c8c2434..a63896c 100644 --- a/internal/coder/coder.go +++ b/internal/coder/coder.go @@ -3,17 +3,15 @@ package coder import ( "fmt" + "github.com/etilite/qr-coder/internal/model" "github.com/skip2/go-qrcode" ) -const utf8BOM = "\ufeff" - -type QRCode struct { - Content string `json:"content"` - Size int `json:"size"` -} +const ( + utf8BOM = "\ufeff" +) -func (code *QRCode) Generate() ([]byte, error) { +func Encode(code model.QRCode) ([]byte, error) { qrCode, err := qrcode.Encode(utf8BOM+code.Content, qrcode.Medium, code.Size) if err != nil { return nil, fmt.Errorf("could not generate a QR code: %v", err) diff --git a/internal/coder/coder_test.go b/internal/coder/coder_test.go new file mode 100644 index 0000000..b6a27d3 --- /dev/null +++ b/internal/coder/coder_test.go @@ -0,0 +1,77 @@ +package coder + +import ( + "testing" + + "github.com/etilite/qr-coder/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestQRCode_Generate(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + code := model.QRCode{ + Content: "test", + Size: 32, + } + + got, err := Encode(code) + + assert.NoError(t, err) + assert.NotEmpty(t, got) + }) + + t.Run("encode error", func(t *testing.T) { + t.Parallel() + + code := model.QRCode{ + Content: tooBigContent, + Size: 32, + } + + got, err := Encode(code) + + assert.Error(t, err) + assert.Empty(t, got) + }) +} + +const tooBigContent = `this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_` diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go index f446db8..0f89553 100644 --- a/internal/delivery/http/handler.go +++ b/internal/delivery/http/handler.go @@ -3,40 +3,56 @@ package http import ( "encoding/json" "fmt" - "log" + "log/slog" "net/http" "github.com/etilite/qr-coder/internal/coder" + "github.com/etilite/qr-coder/internal/model" ) type QRCodeHandler struct { + encode func(code model.QRCode) ([]byte, error) } -func (h *QRCodeHandler) handle() http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - qrCode := coder.QRCode{} - err := json.NewDecoder(request.Body).Decode(&qrCode) +func NewQRCodeHandler() *QRCodeHandler { + return &QRCodeHandler{encode: coder.Encode} +} - //writer.Header().Set("Content-Type", "application/json") +func (h *QRCodeHandler) handle() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := r.Body.Close(); err != nil { + slog.Error("handler: failed to close request body", "error", err) + } + }() + + qrCode := model.QRCode{} + + if err := json.NewDecoder(r.Body).Decode(&qrCode); err != nil { + err = fmt.Errorf("failed to decode JSON: %v", err) + slog.Error("handler: bad request", "error", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - if err != nil { - err = fmt.Errorf("failed to decode JSON: %w", err) - log.Print(err) - http.Error(writer, err.Error(), http.StatusBadRequest) + if err := qrCode.Validate(); err != nil { + err = fmt.Errorf("failed to validate QR Code: %v", err) + slog.Error("handler: bad request", "error", err) + http.Error(w, err.Error(), http.StatusBadRequest) return } - //var image []byte - image, err := qrCode.Generate() + image, err := h.encode(qrCode) if err != nil { - writer.WriteHeader(400) - json.NewEncoder(writer).Encode( - fmt.Sprintf("Could not generate QR code. %v", err), - ) + err = fmt.Errorf("failed to generate QR-code: %v", err) + slog.Error("handler: internal error", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - writer.Header().Set("Content-Type", "image/png") - writer.Write(image) + w.Header().Set("Content-Type", "image/png") + if _, err := w.Write(image); err != nil { + slog.Error("handler: failed to write response", "error", err) + } } } diff --git a/internal/delivery/http/handler_test.go b/internal/delivery/http/handler_test.go new file mode 100644 index 0000000..0601e38 --- /dev/null +++ b/internal/delivery/http/handler_test.go @@ -0,0 +1,84 @@ +package http + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/etilite/qr-coder/internal/model" + "github.com/stretchr/testify/assert" +) + +func TestQRCodeHandler_handle(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + handler := NewQRCodeHandler() + handleFunc := handler.handle() + + requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) + + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + assert.Equal(t, "image/png", response.Header().Get("Content-Type")) + }) + + t.Run("bad request", func(t *testing.T) { + t.Parallel() + + handler := NewQRCodeHandler() + handleFunc := handler.handle() + + requestBody := strings.NewReader("") + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.Contains(t, response.Body.String(), "failed to decode JSON: EOF") + }) + + t.Run("validation error", func(t *testing.T) { + t.Parallel() + + handler := NewQRCodeHandler() + handleFunc := handler.handle() + + requestBody := strings.NewReader(`{"size": 32, "content": ""}`) + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.Contains(t, response.Body.String(), "failed to validate QR Code: content is empty") + }) + + t.Run("internal server error", func(t *testing.T) { + t.Parallel() + + handler := NewQRCodeHandler() + handler.encode = func(code model.QRCode) ([]byte, error) { + return nil, errors.New("encode error") + } + handleFunc := handler.handle() + + requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusInternalServerError, response.Code) + assert.Contains(t, response.Body.String(), "failed to generate QR-code: encode error") + }) +} diff --git a/internal/delivery/http/mux.go b/internal/delivery/http/mux.go index ef318d3..5c8303a 100644 --- a/internal/delivery/http/mux.go +++ b/internal/delivery/http/mux.go @@ -6,9 +6,9 @@ import ( func newMux() *http.ServeMux { mux := http.NewServeMux() - handler := &QRCodeHandler{} + handler := NewQRCodeHandler() - mux.Handle("/generate", handler.handle()) + mux.Handle("POST /generate", handler.handle()) return mux } diff --git a/internal/delivery/http/mux_test.go b/internal/delivery/http/mux_test.go new file mode 100644 index 0000000..fabe13c --- /dev/null +++ b/internal/delivery/http/mux_test.go @@ -0,0 +1,40 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewMux(t *testing.T) { + t.Parallel() + tests := map[string]struct { + method string + path string + }{ + "generate": { + method: http.MethodPost, + path: "/generate", + }, + } + for name, tt := range tests { + tt := tt + + t.Run(name, func(t *testing.T) { + t.Parallel() + + mux := newMux() + + requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) + request := httptest.NewRequest(tt.method, tt.path, requestBody) + response := httptest.NewRecorder() + + mux.ServeHTTP(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + }) + } +} diff --git a/internal/delivery/http/server.go b/internal/delivery/http/server.go index 10a50d4..73efc38 100644 --- a/internal/delivery/http/server.go +++ b/internal/delivery/http/server.go @@ -8,8 +8,14 @@ import ( "time" ) +type httpServer interface { + ListenAndServe() error + Shutdown(ctx context.Context) error +} + type Server struct { - http *http.Server + http httpServer + addr string } func NewServer(addr string) *Server { @@ -18,30 +24,42 @@ func NewServer(addr string) *Server { Addr: addr, Handler: newMux(), }, + addr: addr, } } -func (s *Server) Run() error { - slog.Info("http-server: starting web server", "address", s.http.Addr) - +func (s *Server) Run(ctx context.Context) error { + errCh := make(chan error, 1) go func() { - if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - slog.Error("http-server: failed to serve", "error", err) - } + <-ctx.Done() - slog.Info("http-server: stopped gracefully") + errCh <- s.shutdown() }() + slog.Info("http-server: starting web server", "address", s.addr) + + if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("http-server: failed to serve", "error", err) + return err + } + + slog.Info("http-server: shutting down") + + if err := <-errCh; err != nil { + slog.Error("http-server: error stopping server", "error", err) + return err + } + + slog.Info("http-server: stopped gracefully") + return nil } -func (s *Server) Stop() error { +func (s *Server) shutdown() error { shutdownCtx, done := context.WithTimeout(context.Background(), 5*time.Second) defer done() - slog.Info("http-server: shutting down") if err := s.http.Shutdown(shutdownCtx); err != nil { - slog.Error("http-server: shutdown failed", "error", err) return err } diff --git a/internal/delivery/http/server_test.go b/internal/delivery/http/server_test.go new file mode 100644 index 0000000..6c2e14b --- /dev/null +++ b/internal/delivery/http/server_test.go @@ -0,0 +1,83 @@ +package http + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestServer(t *testing.T) { + t.Parallel() + + t.Run("success run and shutdown", func(t *testing.T) { + server := NewServer(":12345") + server.http = newHttpServerMock(false, false) + + ctx, cancel := context.WithTimeout(context.Background(), 32*time.Millisecond) + defer cancel() + + err := server.Run(ctx) + + assert.NoError(t, err) + }) + + t.Run("error run", func(t *testing.T) { + server := NewServer(":12345") + server.http = newHttpServerMock(true, false) + + err := server.Run(context.Background()) + + assert.Error(t, err) + assert.ErrorContains(t, err, "listen error") + }) + + t.Run("error shutdown", func(t *testing.T) { + server := NewServer(":12345") + server.http = newHttpServerMock(false, true) + + ctx, cancel := context.WithTimeout(context.Background(), 32*time.Millisecond) + defer cancel() + + err := server.Run(ctx) + + assert.Error(t, err) + assert.ErrorContains(t, err, "shutdown error") + }) + +} + +func newHttpServerMock(listenErr, shutdownErr bool) *mockHttpServer { + return &mockHttpServer{ + ch: make(chan bool), + listenErr: listenErr, + shutdownErr: shutdownErr, + } +} + +type mockHttpServer struct { + ch chan bool + listenErr bool + shutdownErr bool +} + +func (s *mockHttpServer) ListenAndServe() error { + if s.listenErr { + return errors.New("listen error") + } + + <-s.ch + + return nil +} +func (s *mockHttpServer) Shutdown(_ context.Context) error { + s.ch <- true + + if s.shutdownErr { + return errors.New("shutdown error") + } + + return nil +} diff --git a/internal/model/qrcode.go b/internal/model/qrcode.go new file mode 100644 index 0000000..c7126b8 --- /dev/null +++ b/internal/model/qrcode.go @@ -0,0 +1,23 @@ +package model + +import "fmt" + +const ( + minSize = 32 + maxSize = 1024 +) + +type QRCode struct { + Content string `json:"content"` + Size int `json:"size"` +} + +func (code *QRCode) Validate() error { + if code.Content == "" { + return fmt.Errorf("content is empty") + } + if code.Size < minSize || code.Size > maxSize { + return fmt.Errorf("invalid size: %d, must be at least %d and at most %d", code.Size, minSize, maxSize) + } + return nil +} diff --git a/internal/model/qrcode_test.go b/internal/model/qrcode_test.go new file mode 100644 index 0000000..27245b4 --- /dev/null +++ b/internal/model/qrcode_test.go @@ -0,0 +1,72 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQRCode_ValidateSuccess(t *testing.T) { + t.Parallel() + + code := &QRCode{ + Content: "test", + Size: 32, + } + + err := code.Validate() + assert.NoError(t, err) +} + +func TestQRCode_ValidateErr(t *testing.T) { + t.Parallel() + + type fields struct { + content string + size int + } + tests := map[string]struct { + fields fields + want string + }{ + "empty content": { + fields: fields{ + content: "", + size: 32, + }, + want: "content is empty", + }, + "size is small": { + fields: fields{ + content: "test", + size: -1, + }, + want: "invalid size: -1, must be at least 32 and at most 1024", + }, + "size is big": { + fields: fields{ + content: "test", + size: 1025, + }, + want: "invalid size: 1025, must be at least 32 and at most 1024", + }, + } + + for name, tt := range tests { + tt := tt + + t.Run(name, func(t *testing.T) { + t.Parallel() + + code := &QRCode{ + Content: tt.fields.content, + Size: tt.fields.size, + } + + err := code.Validate() + + assert.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +}