Skip to content

Commit

Permalink
add user service and fix bugs
Browse files Browse the repository at this point in the history
  • Loading branch information
denpeshkov committed Feb 2, 2024
1 parent edec93c commit bd7dd38
Show file tree
Hide file tree
Showing 13 changed files with 324 additions and 29 deletions.
1 change: 1 addition & 0 deletions cmd/greenlight/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
12 changes: 6 additions & 6 deletions internal/greenlight/movie.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
89 changes: 89 additions & 0 deletions internal/greenlight/user.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion internal/http/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 6 additions & 6 deletions internal/http/movie.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
// Server represents an HTTP server.
type Server struct {
MovieService greenlight.MovieService
UserService greenlight.UserService

opts options

Expand All @@ -38,6 +39,7 @@ func NewServer(addr string, opts ...Option) *Server {

s.registerHealthCheckHandlers()
s.registerMovieHandlers()
s.registerUserHandlers()

return s
}
Expand Down
77 changes: 77 additions & 0 deletions internal/http/user.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS users;
10 changes: 10 additions & 0 deletions internal/postgres/migrations/005_create_users_table.up.sql
Original file line number Diff line number Diff line change
@@ -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
);
31 changes: 15 additions & 16 deletions internal/postgres/movie.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"

"github.com/denpeshkov/greenlight/internal/greenlight"
Expand All @@ -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()
Expand All @@ -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)
}
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit bd7dd38

Please sign in to comment.