From bd7dd380eae5f5a29ac63379a845e49c45d75103 Mon Sep 17 00:00:00 2001 From: Denis Peshkov Date: Fri, 2 Feb 2024 16:25:36 +0400 Subject: [PATCH] add user service and fix bugs --- cmd/greenlight/main.go | 1 + go.mod | 2 + go.sum | 2 + internal/greenlight/movie.go | 12 +- internal/greenlight/user.go | 89 ++++++++++++++ internal/http/middleware.go | 2 +- internal/http/movie.go | 12 +- internal/http/server.go | 2 + internal/http/user.go | 77 ++++++++++++ .../005_create_users_table.down.sql | 1 + .../migrations/005_create_users_table.up.sql | 10 ++ internal/postgres/movie.go | 31 +++-- internal/postgres/user.go | 112 ++++++++++++++++++ 13 files changed, 324 insertions(+), 29 deletions(-) create mode 100644 internal/greenlight/user.go create mode 100644 internal/http/user.go create mode 100644 internal/postgres/migrations/005_create_users_table.down.sql create mode 100644 internal/postgres/migrations/005_create_users_table.up.sql create mode 100644 internal/postgres/user.go diff --git a/cmd/greenlight/main.go b/cmd/greenlight/main.go index acc89aa..e3e8f72 100644 --- a/cmd/greenlight/main.go +++ b/cmd/greenlight/main.go @@ -105,6 +105,7 @@ func run(cfg *Config, logger *slog.Logger) error { // Setting up services srv.MovieService = postgres.NewMovieService(db) + srv.UserService = postgres.NewUserService(db) // Setting up HTTP server err = srv.Open() diff --git a/go.mod b/go.mod index 697a928..be48ab5 100644 --- a/go.mod +++ b/go.mod @@ -5,3 +5,5 @@ go 1.22 require github.com/lib/pq v1.10.9 require golang.org/x/time v0.5.0 + +require golang.org/x/crypto v0.18.0 // indirect diff --git a/go.sum b/go.sum index 58ef1e3..199c836 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/internal/greenlight/movie.go b/internal/greenlight/movie.go index 5fdc0eb..8bcc3c3 100644 --- a/internal/greenlight/movie.go +++ b/internal/greenlight/movie.go @@ -12,7 +12,7 @@ type Movie struct { ReleaseDate time.Time `json:"release_date,omitempty"` Runtime int `json:"runtime,omitempty"` Genres []string `json:"genres,omitempty"` - Version int32 `json:"version"` + Version int32 `json:"-"` } // Valid returns an error if the validation fails, otherwise nil. @@ -89,9 +89,9 @@ func (f *MovieFilter) Valid() error { // MovieService is a service for managing movies. type MovieService interface { - GetMovie(ctx context.Context, id int64) (*Movie, error) - GetMovies(cts context.Context, filter MovieFilter) ([]*Movie, error) - CreateMovie(ctx context.Context, m *Movie) error - UpdateMovie(ctx context.Context, m *Movie) error - DeleteMovie(ctx context.Context, id int64) error + Get(ctx context.Context, id int64) (*Movie, error) + GetAll(cts context.Context, filter MovieFilter) ([]*Movie, error) + Create(ctx context.Context, m *Movie) error + Update(ctx context.Context, m *Movie) error + Delete(ctx context.Context, id int64) error } diff --git a/internal/greenlight/user.go b/internal/greenlight/user.go new file mode 100644 index 0000000..770823b --- /dev/null +++ b/internal/greenlight/user.go @@ -0,0 +1,89 @@ +package greenlight + +import ( + "context" + "errors" + "fmt" + "net/mail" + "unicode/utf8" + + "golang.org/x/crypto/bcrypt" +) + +// User represents a user. +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Password Password `json:"-"` + Activated bool `json:"activated"` + Version int `json:"-"` +} + +// Valid returns an error if the validation fails, otherwise nil. +func (u *User) Valid() error { + err := NewInvalidError("User is invalid.") + + if u.ID < 0 { + err.AddViolationMsg("ID", "Must be greater or equal to 0.") + } + + if u.Name == "" { + err.AddViolationMsg("Name", "Must be provided.") + } + if utf8.RuneCount([]byte(u.Name)) > 500 { + err.AddViolationMsg("Name", "Must not be more than 500 characters long.") + } + + if u.Email == "" { + err.AddViolationMsg("Email", "Must be provided.") + } + if _, e := mail.ParseAddress(u.Email); e != nil { + err.AddViolationMsg("Email", "Is invalid.") + } + + if len(u.Password) == 0 { + err.AddViolationMsg("Password", "Must be provided.") + } + + if len(err.violations) != 0 { + return err + } + return nil +} + +// Password represents a hash of the user password. +type Password []byte + +// NewPasswords generates a hashed password from the plaintext password. +func NewPassword(plaintext string) (Password, error) { + op := "greenlight.NewPassword" + + hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), 12) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + return hash, nil +} + +// Matches tests whether the provided plaintext password matches the hashed password. +func (p *Password) Matches(plaintext string) (bool, error) { + op := "greenlight.password.Matches" + + if err := bcrypt.CompareHashAndPassword(*p, []byte(plaintext)); err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return false, nil + default: + return false, fmt.Errorf("%s: %w", op, err) + } + } + return true, nil +} + +// UserService is a service for managing users. +type UserService interface { + Get(ctx context.Context, email string) (*User, error) + Create(ctx context.Context, u *User) error + Update(ctx context.Context, u *User) error +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go index c670bd3..c17c619 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -98,7 +98,7 @@ func (s *Server) rateLimit(h http.Handler) http.Handler { time.Sleep(time.Minute) lims.Range(func(ip, v any) bool { - clim := v.(*clientLim) + clim := v.(clientLim) if time.Since(clim.lastSeen) > 3*time.Minute { lims.Delete(ip) } diff --git a/internal/http/movie.go b/internal/http/movie.go index e106f17..49d9e24 100644 --- a/internal/http/movie.go +++ b/internal/http/movie.go @@ -30,7 +30,7 @@ func (s *Server) handleMovieGet(w http.ResponseWriter, r *http.Request) { return } - m, err := s.MovieService.GetMovie(r.Context(), id) + m, err := s.MovieService.Get(r.Context(), id) if err != nil { s.Error(w, r, fmt.Errorf("%s: %w", op, err)) return @@ -101,7 +101,7 @@ func (s *Server) handleMoviesGet(w http.ResponseWriter, r *http.Request) { return } - movies, err := s.MovieService.GetMovies(r.Context(), filter) + movies, err := s.MovieService.GetAll(r.Context(), filter) if err != nil { s.Error(w, r, fmt.Errorf("%s: %w", op, err)) return @@ -156,7 +156,7 @@ func (s *Server) handleMovieCreate(w http.ResponseWriter, r *http.Request) { s.Error(w, r, err) return } - if err := s.MovieService.CreateMovie(r.Context(), m); err != nil { + if err := s.MovieService.Create(r.Context(), m); err != nil { s.Error(w, r, fmt.Errorf("%s: %w", op, err)) return } @@ -194,7 +194,7 @@ func (s *Server) handleMovieUpdate(w http.ResponseWriter, r *http.Request) { return } - m, err := s.MovieService.GetMovie(r.Context(), id) + m, err := s.MovieService.Get(r.Context(), id) if err != nil { s.Error(w, r, fmt.Errorf("%s: %w", op, err)) } @@ -228,7 +228,7 @@ func (s *Server) handleMovieUpdate(w http.ResponseWriter, r *http.Request) { s.Error(w, r, err) return } - if err := s.MovieService.UpdateMovie(r.Context(), m); err != nil { + if err := s.MovieService.Update(r.Context(), m); err != nil { s.Error(w, r, fmt.Errorf("%s: %w", op, err)) return } @@ -264,7 +264,7 @@ func (s *Server) handleMovieDelete(w http.ResponseWriter, r *http.Request) { return } - if err := s.MovieService.DeleteMovie(r.Context(), id); err != nil { + if err := s.MovieService.Delete(r.Context(), id); err != nil { s.Error(w, r, fmt.Errorf("%s: %w", op, err)) return } diff --git a/internal/http/server.go b/internal/http/server.go index 3bbd6e3..6ee3afd 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -14,6 +14,7 @@ import ( // Server represents an HTTP server. type Server struct { MovieService greenlight.MovieService + UserService greenlight.UserService opts options @@ -38,6 +39,7 @@ func NewServer(addr string, opts ...Option) *Server { s.registerHealthCheckHandlers() s.registerMovieHandlers() + s.registerUserHandlers() return s } diff --git a/internal/http/user.go b/internal/http/user.go new file mode 100644 index 0000000..a1305c8 --- /dev/null +++ b/internal/http/user.go @@ -0,0 +1,77 @@ +package http + +import ( + "fmt" + "net/http" + + "github.com/denpeshkov/greenlight/internal/greenlight" +) + +func (s *Server) registerUserHandlers() { + s.router.HandleFunc("POST /v1/users", s.handleUserCreate) +} + +// handleUserCreate handles requests to create (register) a user. +func (s *Server) handleUserCreate(w http.ResponseWriter, r *http.Request) { + op := "http.Server.handleUserCreate" + + var req struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + } + if err := s.readRequest(w, r, &req); err != nil { + s.Error(w, r, fmt.Errorf("%s: %w", op, err)) + return + } + + err := greenlight.NewInvalidError("User is invalid.") + if req.Password == "" { + err.AddViolationMsg("Password", "Must be provided.") + } + if len(req.Password) < 8 { + err.AddViolationMsg("Password", "Must be at least 8 characters long.") + } + if len(req.Password) > 72 { + err.AddViolationMsg("Password", "Must not be more than 72 bytes long.") + } + if len(err.Violations()) != 0 { + s.Error(w, r, err) + return + } + + u := &greenlight.User{ + Name: req.Name, + Email: req.Email, + Activated: false, + } + pass, errPas := greenlight.NewPassword(req.Password) + if errPas != nil { + s.Error(w, r, fmt.Errorf("%s: %w", op, err)) + } + u.Password = pass + + if err := u.Valid(); err != nil { + s.Error(w, r, err) + return + } + if err := s.UserService.Create(r.Context(), u); err != nil { + s.Error(w, r, fmt.Errorf("%s: %w", op, err)) + return + } + + resp := struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + }{ + ID: u.ID, + Name: u.Name, + Email: u.Email, + } + + if err := s.sendResponse(w, r, http.StatusCreated, resp, nil); err != nil { + s.Error(w, r, fmt.Errorf("%s: %w", op, err)) + return + } +} diff --git a/internal/postgres/migrations/005_create_users_table.down.sql b/internal/postgres/migrations/005_create_users_table.down.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/internal/postgres/migrations/005_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/internal/postgres/migrations/005_create_users_table.up.sql b/internal/postgres/migrations/005_create_users_table.up.sql new file mode 100644 index 0000000..ae36576 --- /dev/null +++ b/internal/postgres/migrations/005_create_users_table.up.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS users ( + id bigserial PRIMARY KEY, + name text NOT NULL, + email citext UNIQUE NOT NULL, + password_hash bytea NOT NULL, + activated bool NOT NULL, + version integer NOT NULL DEFAULT 1 +); \ No newline at end of file diff --git a/internal/postgres/movie.go b/internal/postgres/movie.go index f729a7d..9e827a9 100644 --- a/internal/postgres/movie.go +++ b/internal/postgres/movie.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "log/slog" "strings" "github.com/denpeshkov/greenlight/internal/greenlight" @@ -14,20 +13,20 @@ import ( // MovieService represents a service for managing movies backed by PostgreSQL. type MovieService struct { - db *DB - logger *slog.Logger + db *DB } +var _ greenlight.MovieService = (*MovieService)(nil) + // NewMovieService returns a new instance of [MovieService]. func NewMovieService(db *DB) *MovieService { return &MovieService{ - db: db, - logger: newLogger(), + db: db, } } -func (s *MovieService) GetMovie(ctx context.Context, id int64) (*greenlight.Movie, error) { - op := "postgres.MovieService.GetMovie" +func (s *MovieService) Get(ctx context.Context, id int64) (*greenlight.Movie, error) { + op := "postgres.MovieService.Get" ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) defer cancel() @@ -44,7 +43,7 @@ func (s *MovieService) GetMovie(ctx context.Context, id int64) (*greenlight.Movi if err := tx.QueryRowContext(ctx, query, args...).Scan(&m.ID, &m.Title, &m.ReleaseDate, &m.Runtime, pq.Array(&m.Genres), &m.Version); err != nil { switch { case errors.Is(err, sql.ErrNoRows): - return nil, greenlight.NewNotFoundError("Movie with id=%d is not found.", id) + return nil, greenlight.NewNotFoundError("Movie not found.") default: return nil, fmt.Errorf("%s: movie with id=%d: %w", op, id, err) } @@ -56,8 +55,8 @@ func (s *MovieService) GetMovie(ctx context.Context, id int64) (*greenlight.Movi return &m, nil } -func (s *MovieService) GetMovies(ctx context.Context, filter greenlight.MovieFilter) ([]*greenlight.Movie, error) { - op := "postgres.MovieService.GetMovies" +func (s *MovieService) GetAll(ctx context.Context, filter greenlight.MovieFilter) ([]*greenlight.Movie, error) { + op := "postgres.MovieService.GetAll" ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) defer cancel() @@ -104,8 +103,8 @@ func (s *MovieService) GetMovies(ctx context.Context, filter greenlight.MovieFil return movies, nil } -func (s *MovieService) UpdateMovie(ctx context.Context, m *greenlight.Movie) error { - op := "postgres.MovieService.UpdateMovie" +func (s *MovieService) Update(ctx context.Context, m *greenlight.Movie) error { + op := "postgres.MovieService.Update" ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) defer cancel() @@ -133,8 +132,8 @@ func (s *MovieService) UpdateMovie(ctx context.Context, m *greenlight.Movie) err return nil } -func (s *MovieService) DeleteMovie(ctx context.Context, id int64) error { - op := "postgres.MovieService.DeleteMovie" +func (s *MovieService) Delete(ctx context.Context, id int64) error { + op := "postgres.MovieService.Delete" ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) defer cancel() @@ -166,8 +165,8 @@ func (s *MovieService) DeleteMovie(ctx context.Context, id int64) error { return nil } -func (s *MovieService) CreateMovie(ctx context.Context, m *greenlight.Movie) error { - op := "postgres.MovieService.CreateMovie" +func (s *MovieService) Create(ctx context.Context, m *greenlight.Movie) error { + op := "postgres.MovieService.Create" ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) defer cancel() diff --git a/internal/postgres/user.go b/internal/postgres/user.go new file mode 100644 index 0000000..a379a07 --- /dev/null +++ b/internal/postgres/user.go @@ -0,0 +1,112 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/denpeshkov/greenlight/internal/greenlight" +) + +// UserService represents a service for managing users backed by PostgreSQL. +type UserService struct { + db *DB +} + +var _ greenlight.UserService = (*UserService)(nil) + +// NewUserService returns a new instance of [UserService]. +func NewUserService(db *DB) *UserService { + return &UserService{ + db: db, + } +} + +func (s *UserService) Get(ctx context.Context, email string) (*greenlight.User, error) { + op := "postgres.UserService.Get" + + ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) + defer cancel() + + tx, err := s.db.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + defer tx.Rollback() + + query := `SELECT id, name, email, password_hash, activated, version FROM users WHERE email = $1` + args := []any{email} + var u greenlight.User + if err := tx.QueryRowContext(ctx, query, args...).Scan(&u.ID, &u.Name, &u.Password, &u.Activated, &u.Version); err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, greenlight.NewNotFoundError("User not found.") + default: + return nil, fmt.Errorf("%s: %w", op, err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + return &u, nil +} + +func (s *UserService) Create(ctx context.Context, u *greenlight.User) error { + op := "postgres.UserService.Create" + + ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) + defer cancel() + + tx, err := s.db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + defer tx.Rollback() + + query := `INSERT INTO users (name, email, password_hash, activated) VALUES ($1, $2, $3, $4) RETURNING id, version` + args := []any{u.ID, u.Email, u.Password, u.Activated} + if err := tx.QueryRowContext(ctx, query, args...).Scan(&u.ID, &u.Version); err != nil { + switch { + case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: + return greenlight.NewConflictError("A user with this email already exists.") + default: + return fmt.Errorf("%s: %w", op, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("%s: %w", op, err) + } + return nil +} + +func (s *UserService) Update(ctx context.Context, u *greenlight.User) error { + op := "postgres.UserService.Update" + + ctx, cancel := context.WithTimeout(ctx, s.db.opts.queryTimeout) + defer cancel() + + tx, err := s.db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + defer tx.Rollback() + + query := `UPDATE users SET (name, email, password_hash, activated, version) = ($1, $2, $3, $4, version+1) WHERE id = $5 AND version = $6 RETURNING version` + args := []any{u.Name, u.Email, u.Password, u.Activated, u.ID, u.Version} + if err := tx.QueryRowContext(ctx, query, args...).Scan(&u.ID, &u.Version); err != nil { + switch { + case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: + return greenlight.NewConflictError("A user with this email already exists.") + default: + return fmt.Errorf("%s: %w", op, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("%s: %w", op, err) + } + return nil +}