initial commit for bookish-server and local environment

This commit is contained in:
David Thompson
2020-05-26 00:30:20 -04:00
parent d634e9d00f
commit 2bc790c87b
48 changed files with 15860 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
.env
+37 -1
View File
@@ -1,2 +1,38 @@
# bookish
Bookish bookstore sells books!
bookish is a customer relationship model for library systems. It keeps track of renters and the books they have out.
## Prerequisites
- You will need [Docker Desktop](https://docs.docker.com/desktop/) installed.
- You will need [Golang](https://golang.org/doc/install) installed.
## Getting Started
To run bookish locally you can use the docker-compose in the root directory to spin up the local environment:
```
docker-compose up -d
```
This will start a local MySQL container on port `3306` using the `root` user and password `123pass`. A volume will be mounted that holds spin up SQL scripts that will prepare the database for you.
You will also need to add the following `.env` file to the `bookish-server/cmd/api` folder:
```
export APIPORT=3000
export LOGLEVEL=Debug
export DBHOST=localhost
export DBUSER=root
export DBPASS=123pass
export DBPORT=3306
export BOOKSHOST=<books API endpoint>
```
## Installing
Before running the server call `go mod tidy` from the `bookish/bookish-server` directory to install any dependencies.
## Testing
To run unit tests for `bookish-server` run `go test ./...` from the `bookish/bookish-server` directory.
## Usage
+9
View File
@@ -0,0 +1,9 @@
package bookish_server
// Book describes a book object that will be returned from the books API.
type Book struct {
Title string `json:"title"`
Author *string `json:"author"`
YearPublished *string `json:"year"`
ISBN *string `json:"isbn"`
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"github.com/kelseyhightower/envconfig"
)
type config struct {
APIPort string
LogLevel string
DBHost string
DBUser string
DBPass string
DBPort string
BooksHost string
}
func getConfig() (cfg config, err error) {
err = envconfig.Process("", &cfg)
return
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/davidlick/bookish/bookish-server/cmd/http"
"github.com/davidlick/bookish/bookish-server/inventory"
"github.com/davidlick/bookish/bookish-server/mysql"
"github.com/davidlick/bookish/bookish-server/renter"
"github.com/sirupsen/logrus"
)
var (
cfg config
err error
logger = logrus.New()
)
func init() {
// Get configuration from the environment.
cfg, err = getConfig()
if err != nil {
log.Fatal(err.Error())
}
// Configure logger.
logger.SetOutput(os.Stdout)
logLevel, err := logrus.ParseLevel(cfg.LogLevel)
if err != nil {
log.Fatal(err.Error())
}
logger.SetLevel(logLevel)
logger.SetReportCaller(true)
logger.SetFormatter(&logrus.JSONFormatter{})
}
func main() {
// Connect to application database and create store.
appDSN := fmt.Sprintf("%s:%s@tcp(%s:%s)/bookish?parseTime=True&loc=Local",
cfg.DBUser,
cfg.DBPass,
cfg.DBHost,
cfg.DBPort,
)
appDB, err := mysql.NewStore(appDSN)
if err != nil {
log.Fatalf("could not connect to application database: %v", err.Error())
}
// Build domain services.
renterService := renter.NewService(appDB)
inventoryService := inventory.NewService(appDB)
// Initialize server.
server := http.Server{
Port: cfg.APIPort,
BooksHost: cfg.BooksHost,
Logger: logger,
Renter: renterService,
Inventory: inventoryService,
}
// Create channels to listen for OS signals.
serverErrors := make(chan error, 1)
osSignals := make(chan os.Signal, 1)
signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM)
// Start the server.
go func() {
serverErrors <- server.Run()
}()
// Block until an error or interrupt/sigterm is received.
select {
case err := <-serverErrors:
log.Fatalf("error starting server: %v", err.Error())
case <-osSignals:
log.Println("starting server shutdown...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
log.Fatalf("error shutting down http server: %v", err.Error())
}
}
}
+117
View File
@@ -0,0 +1,117 @@
package http
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"time"
bookish "github.com/davidlick/bookish/bookish-server"
"github.com/davidlick/bookish/bookish-server/inventory"
"github.com/davidlick/bookish/bookish-server/renter"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/sirupsen/logrus"
)
// Server holds dependencies of the API.
type Server struct {
Port string
BooksHost string
Logger *logrus.Logger
Renter renter.Service
Inventory inventory.Service
// LibraryMap holds a map of values that are returned by the books API. This is used to check
// if a book exists in the library.
LibraryMap struct {
UpdatedTime time.Time
Map map[string]bookish.Book
}
server *http.Server
}
// Run configures and starts the server.
func (s *Server) Run() error {
s.server = &http.Server{
Addr: ":" + s.Port,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
Handler: s.BuildRoutes(),
}
s.Logger.Info("server launched on port: " + s.Port)
return s.server.ListenAndServe()
}
// Shutdown attempts to gracefully shutdown the server.
func (s *Server) Shutdown(ctx context.Context) error {
return s.server.Shutdown(ctx)
}
// UpdateMap will query the books API if 1 hour has passed since the data was last refreshed.
func (s *Server) UpdateMap() error {
// If it has not been at least an hour since the last refresh we do not need to refresh.
if !time.Now().After(s.LibraryMap.UpdatedTime.Add(1 * time.Hour)) {
return nil
}
resp, err := http.Get(fmt.Sprintf("%s/api/v1/books", s.BooksHost))
if err != nil {
return err
}
respBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
var books []bookish.Book
err = json.Unmarshal(respBytes, &books)
if err != nil {
return err
}
// Empty the existing map.
s.LibraryMap.Map = map[string]bookish.Book{}
for _, book := range books {
s.LibraryMap.Map[book.Title] = book
}
s.LibraryMap.UpdatedTime = time.Now()
return nil
}
// BuildRoutes builds the API endpoints and connects their handlers.
func (s *Server) BuildRoutes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Group(func(r chi.Router) {
r.Route("/api", func(r chi.Router) {
r.Route("/v1", func(r chi.Router) {
r.Route("/renters", func(r chi.Router) {
r.Post("/", s.registerRenter)
r.Get("/", s.listRenters)
r.Route("/{renterId}", func(r chi.Router) {
r.Use(s.renterCtx)
r.Get("/", s.fetchRenter)
r.Route("/books", func(r chi.Router) {
r.Use(s.bookInLibrary)
r.Post("/checkout", s.checkoutBook)
r.Post("/return", s.returnBook)
})
})
})
})
})
})
return r
}
@@ -0,0 +1,61 @@
package http
import (
"errors"
"fmt"
"net/http"
bookish "github.com/davidlick/bookish/bookish-server"
"github.com/davidlick/bookish/bookish-server/internal"
"github.com/davidlick/bookish/bookish-server/inventory"
)
// checkoutBook is used to checkout a book for a renter.
func (s *Server) checkoutBook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
renter, ok := ctx.Value(internal.CtxRenter).(bookish.Renter)
if !ok {
http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
return
}
bookTitle, ok := ctx.Value(internal.CtxBookTitle).(string)
if !ok {
http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
return
}
err := s.Inventory.CheckoutBook(renter.ID, bookTitle)
if err != nil {
if errors.Is(inventory.ErrUnavailableBook, err) {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
// returnBook returns a book for a renter.
func (s *Server) returnBook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
renter, ok := ctx.Value(internal.CtxRenter).(bookish.Renter)
if !ok {
http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
return
}
bookTitle, ok := ctx.Value(internal.CtxBookTitle).(string)
if !ok {
http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
return
}
err := s.Inventory.ReturnBook(renter.ID, bookTitle)
if err != nil {
fmt.Println(err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
+73
View File
@@ -0,0 +1,73 @@
package http
import (
"context"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"strconv"
"github.com/davidlick/bookish/bookish-server/internal"
"github.com/davidlick/bookish/bookish-server/mysql"
"github.com/go-chi/chi"
)
// renterCtx is a convenience handler to fetch the renter with the provided renterId URLParam.
func (s *Server) renterCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "renterId")
renterId, err := strconv.Atoi(id)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
renter, err := s.Renter.FetchRenter(renterId)
if err != nil && errors.Is(mysql.ErrNotFound, err) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
ctx = context.WithValue(ctx, internal.CtxRenter, renter)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// bookInLibrary is a convenience handler that queries the books API for a map of books available
// at the library.
func (s *Server) bookInLibrary(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
s.UpdateMap()
reqBytes, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
var req struct {
Title string `json:"title"`
}
err = json.Unmarshal(reqBytes, &req)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if _, found := s.LibraryMap.Map[req.Title]; !found {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx = context.WithValue(ctx, internal.CtxBookTitle, req.Title)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -0,0 +1,61 @@
package http
import (
"encoding/json"
"io/ioutil"
"net/http"
bookish "github.com/davidlick/bookish/bookish-server"
"github.com/davidlick/bookish/bookish-server/internal"
)
// registerRenter creates a record of a new renter.
func (s *Server) registerRenter(w http.ResponseWriter, r *http.Request) {
reqBytes, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
var renter bookish.Renter
err = json.Unmarshal(reqBytes, &renter)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
id, err := s.Renter.RegisterRenter(renter.Name, renter.Address, renter.Email, renter.PhoneNumber)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
resp := struct {
ID int `json:"renterId"`
}{ID: id}
json.NewEncoder(w).Encode(resp)
}
// listRenters lists all renters registered in the API.
func (s *Server) listRenters(w http.ResponseWriter, r *http.Request) {
rr, err := s.Renter.ListRenters()
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(rr)
}
// fetchRenter fetches a specific renter's details.
func (s *Server) fetchRenter(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
renter, ok := ctx.Value(internal.CtxRenter).(bookish.Renter)
if !ok {
http.Error(w, http.StatusText(http.StatusUnprocessableEntity), http.StatusUnprocessableEntity)
return
}
json.NewEncoder(w).Encode(renter)
}
+15
View File
@@ -0,0 +1,15 @@
module github.com/davidlick/bookish/bookish-server
go 1.14
require (
github.com/go-chi/chi v4.1.1+incompatible
github.com/go-sql-driver/mysql v1.5.0
github.com/golang/mock v1.4.3
github.com/jmoiron/sqlx v1.2.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/sirupsen/logrus v1.6.0
github.com/stretchr/testify v1.5.1
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 // indirect
golang.org/x/text v0.3.2 // indirect
)
+51
View File
@@ -0,0 +1,51 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262 h1:qsl9y/CJx34tuA7QCPNp86JNJe4spst6Ff8MjvPUdPg=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+6
View File
@@ -0,0 +1,6 @@
package internal
type contextKey string
const CtxRenter contextKey = contextKey("ctx-renter")
const CtxBookTitle contextKey = contextKey("ctx-book-title")
+8
View File
@@ -0,0 +1,8 @@
package inventory
import "errors"
var (
ErrInvalidBook = errors.New("book not supported in library")
ErrUnavailableBook = errors.New("book is not available at this library")
)
+58
View File
@@ -0,0 +1,58 @@
package inventory
// Storage defines behavior for interacting with the inventory store.
type Storage interface {
IsBookAvailable(title string) (available bool, err error)
RenterAlreadyCheckedOut(renterId int, title string) (checkedOut bool, err error)
CheckoutBook(renterId int, title string) error
ReturnBook(renterId int, title string) error
}
type service struct {
store Storage
}
// Service defines behavior for interacting with the inventory service.
type Service interface {
IsBookAvailable(title string) (available bool, err error)
CheckoutBook(renterId int, title string) error
ReturnBook(renterId int, title string) error
}
// NewService creates a new inventory service.
func NewService(s Storage) *service {
return &service{
s,
}
}
// IsBookAvailable checks if a book is available to rent at this library.
func (s *service) IsBookAvailable(title string) (available bool, err error) {
return s.store.IsBookAvailable(title)
}
// CheckoutBook checks a book out to a renter.
func (s *service) CheckoutBook(renterId int, title string) error {
available, err := s.IsBookAvailable(title)
if err != nil {
return err
}
if !available {
return ErrUnavailableBook
}
checkedOut, err := s.store.RenterAlreadyCheckedOut(renterId, title)
// If there was an error or the book is checked out by the renter already we'll
// say the book is unavailable.
if err != nil || checkedOut {
return ErrUnavailableBook
}
return s.store.CheckoutBook(renterId, title)
}
// ReturnBook returns a book for a renter.
func (s *service) ReturnBook(renterId int, title string) error {
return s.store.ReturnBook(renterId, title)
}
+157
View File
@@ -0,0 +1,157 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: inventory.go
// Package inventory is a generated GoMock package.
package inventory
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockStorage is a mock of Storage interface
type MockStorage struct {
ctrl *gomock.Controller
recorder *MockStorageMockRecorder
}
// MockStorageMockRecorder is the mock recorder for MockStorage
type MockStorageMockRecorder struct {
mock *MockStorage
}
// NewMockStorage creates a new mock instance
func NewMockStorage(ctrl *gomock.Controller) *MockStorage {
mock := &MockStorage{ctrl: ctrl}
mock.recorder = &MockStorageMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockStorage) EXPECT() *MockStorageMockRecorder {
return m.recorder
}
// IsBookAvailable mocks base method
func (m *MockStorage) IsBookAvailable(title string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsBookAvailable", title)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsBookAvailable indicates an expected call of IsBookAvailable
func (mr *MockStorageMockRecorder) IsBookAvailable(title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsBookAvailable", reflect.TypeOf((*MockStorage)(nil).IsBookAvailable), title)
}
// RenterAlreadyCheckedOut mocks base method
func (m *MockStorage) RenterAlreadyCheckedOut(renterId int, title string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RenterAlreadyCheckedOut", renterId, title)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RenterAlreadyCheckedOut indicates an expected call of RenterAlreadyCheckedOut
func (mr *MockStorageMockRecorder) RenterAlreadyCheckedOut(renterId, title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenterAlreadyCheckedOut", reflect.TypeOf((*MockStorage)(nil).RenterAlreadyCheckedOut), renterId, title)
}
// CheckoutBook mocks base method
func (m *MockStorage) CheckoutBook(renterId int, title string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CheckoutBook", renterId, title)
ret0, _ := ret[0].(error)
return ret0
}
// CheckoutBook indicates an expected call of CheckoutBook
func (mr *MockStorageMockRecorder) CheckoutBook(renterId, title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckoutBook", reflect.TypeOf((*MockStorage)(nil).CheckoutBook), renterId, title)
}
// ReturnBook mocks base method
func (m *MockStorage) ReturnBook(renterId int, title string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReturnBook", renterId, title)
ret0, _ := ret[0].(error)
return ret0
}
// ReturnBook indicates an expected call of ReturnBook
func (mr *MockStorageMockRecorder) ReturnBook(renterId, title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReturnBook", reflect.TypeOf((*MockStorage)(nil).ReturnBook), renterId, title)
}
// MockService is a mock of Service interface
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
}
// MockServiceMockRecorder is the mock recorder for MockService
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// IsBookAvailable mocks base method
func (m *MockService) IsBookAvailable(title string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsBookAvailable", title)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsBookAvailable indicates an expected call of IsBookAvailable
func (mr *MockServiceMockRecorder) IsBookAvailable(title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsBookAvailable", reflect.TypeOf((*MockService)(nil).IsBookAvailable), title)
}
// CheckoutBook mocks base method
func (m *MockService) CheckoutBook(renterId int, title string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CheckoutBook", renterId, title)
ret0, _ := ret[0].(error)
return ret0
}
// CheckoutBook indicates an expected call of CheckoutBook
func (mr *MockServiceMockRecorder) CheckoutBook(renterId, title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckoutBook", reflect.TypeOf((*MockService)(nil).CheckoutBook), renterId, title)
}
// ReturnBook mocks base method
func (m *MockService) ReturnBook(renterId int, title string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReturnBook", renterId, title)
ret0, _ := ret[0].(error)
return ret0
}
// ReturnBook indicates an expected call of ReturnBook
func (mr *MockServiceMockRecorder) ReturnBook(renterId, title interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReturnBook", reflect.TypeOf((*MockService)(nil).ReturnBook), renterId, title)
}
+157
View File
@@ -0,0 +1,157 @@
package inventory
import (
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestInventory_IsBookAvailable(t *testing.T) {
testCases := []struct {
TestName string
Title string
RenterId int
Available bool
Error error
}{
{
TestName: "success-available",
Title: "Book #1",
Available: true,
},
{
TestName: "success-unavailable",
Title: "Book #2",
Available: false,
},
{
TestName: "failure",
Title: "Book #3",
Error: errors.New("test error"),
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStorage := NewMockStorage(ctrl)
mockStorage.EXPECT().IsBookAvailable(tc.Title).Return(tc.Available, tc.Error)
service := NewService(mockStorage)
available, err := service.IsBookAvailable(tc.Title)
assert.Equal(t, tc.Available, available)
assert.Equal(t, tc.Error, err)
})
}
}
func TestInventory_CheckoutBook(t *testing.T) {
testCases := []struct {
TestName string
RenterId int
Title string
Available bool
AvailableError error
CheckedOut bool
CheckedOutError error
Error error
}{
{
TestName: "success",
RenterId: 1,
Title: "Book #1",
Available: true,
},
{
TestName: "unavailable",
RenterId: 2,
Title: "Book #2",
Available: false,
Error: ErrUnavailableBook,
},
{
TestName: "availability_error",
RenterId: 3,
Title: "Book #3",
AvailableError: errors.New("test error"),
Error: errors.New("test error"),
},
{
TestName: "checked-out",
RenterId: 4,
Title: "Book #4",
Available: true,
CheckedOut: true,
Error: ErrUnavailableBook,
},
{
TestName: "failure",
RenterId: 4,
Title: "Book #4",
Available: true,
Error: errors.New("test error"),
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStorage := NewMockStorage(ctrl)
mockStorage.EXPECT().IsBookAvailable(tc.Title).Return(tc.Available, tc.AvailableError)
if tc.Available && tc.AvailableError == nil {
mockStorage.EXPECT().RenterAlreadyCheckedOut(tc.RenterId, tc.Title).Return(tc.CheckedOut, tc.CheckedOutError)
if !tc.CheckedOut && tc.CheckedOutError == nil {
mockStorage.EXPECT().CheckoutBook(tc.RenterId, tc.Title).Return(tc.Error)
}
}
service := NewService(mockStorage)
err := service.CheckoutBook(tc.RenterId, tc.Title)
assert.Equal(t, tc.Error, err)
})
}
}
func TestInventory_ReturnBook(t *testing.T) {
testCases := []struct {
TestName string
RenterId int
Title string
Error error
}{
{
TestName: "success",
RenterId: 1,
Title: "Book #1",
},
{
TestName: "failure",
RenterId: 2,
Title: "Book #2",
Error: errors.New("test error"),
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStorage := NewMockStorage(ctrl)
mockStorage.EXPECT().ReturnBook(tc.RenterId, tc.Title).Return(tc.Error)
service := NewService(mockStorage)
err := service.ReturnBook(tc.RenterId, tc.Title)
assert.Equal(t, tc.Error, err)
})
}
}
+10
View File
@@ -0,0 +1,10 @@
package mysql
import (
"errors"
)
var (
ErrNotFound = errors.New("no renter found")
ErrAlreadyExists = errors.New("the provided value already exists")
)
+111
View File
@@ -0,0 +1,111 @@
package mysql
import (
"database/sql"
"errors"
"time"
)
// IsBookAvailable queries the database for available books.
func (s *store) IsBookAvailable(title string) (available bool, err error) {
q := `
SELECT
*
FROM
rentals
WHERE
book_title = ?
AND return_date IS NULL;
`
var rr []struct {
ID int `db:"id"`
RenterID int `db:"renter_id"`
RentalDate time.Time `db:"rental_date"`
ReturnDate sql.NullTime `db:"return_date"`
BookTitle string `db:"book_title"`
}
err = s.Select(&rr, q, title)
if err != nil {
// If no records are returned then the book is available for rent.
if errors.Is(sql.ErrNoRows, err) {
return true, nil
}
return false, err
}
// If 5 books are rented out this book is not available.
if len(rr) > 4 {
return false, nil
}
return true, nil
}
// RenterAlreadyCheckedOut queries the database for the given renterId and title. If a record is returned checkedOut will be true.
func (s *store) RenterAlreadyCheckedOut(renterId int, title string) (checkedOut bool, err error) {
q := `
SELECT
*
FROM
rentals
WHERE
renter_id = ?
AND book_title = ?
AND return_date IS NULL;
`
var rr []struct {
ID int `db:"id"`
RenterID int `db:"renter_id"`
RentalDate time.Time `db:"rental_date"`
ReturnDate sql.NullTime `db:"return_date"`
BookTitle string `db:"book_title"`
}
err = s.Select(&rr, q, renterId, title)
if err != nil {
if errors.Is(sql.ErrNoRows, err) {
return false, nil
}
return false, err
}
if len(rr) == 0 {
return false, nil
}
return true, nil
}
// CheckoutBook inserts a record indicating the book is checked out to the renter.
func (s *store) CheckoutBook(renterId int, title string) error {
q := `
INSERT INTO
rentals (renter_id, rental_date, book_title)
VALUES
(?, ?, ?);
`
_, err := s.Exec(q, renterId, time.Now(), title)
return err
}
// ReturnBook sets the return_date field to now indicating the book has been returned.
func (s *store) ReturnBook(renterId int, title string) error {
q := `
UPDATE
rentals
SET
return_date = ?
WHERE
renter_id = ?
AND book_title = ?;
`
_, err := s.Exec(q, time.Now(), renterId, title)
return err
}
+20
View File
@@ -0,0 +1,20 @@
package mysql
import (
"github.com/jmoiron/sqlx"
)
// store holds a connection to the application database.
type store struct {
*sqlx.DB
}
// NewStore creates a new connection to the database and returns a store containing the connection.
func NewStore(dsn string) (s *store, err error) {
db, err := sqlx.Open("mysql", dsn)
if err != nil {
return s, err
}
return &store{db}, nil
}
+66
View File
@@ -0,0 +1,66 @@
package mysql
import (
"database/sql"
"errors"
bookish "github.com/davidlick/bookish/bookish-server"
"github.com/go-sql-driver/mysql"
)
// ListAll returns all renter records.
func (s *store) ListAll() (rr []bookish.Renter, err error) {
q := `
SELECT
*
FROM
renters
`
err = s.Select(&rr, q)
if err != nil && errors.Is(sql.ErrNoRows, err) {
return nil, ErrNotFound
}
return
}
// FetchDetails queries the database for a specific renter.
func (s *store) FetchDetails(id int) (r bookish.Renter, err error) {
q := `
SELECT
*
FROM
renters
WHERE
id = ?
`
err = s.Get(&r, q, id)
if err != nil && errors.Is(sql.ErrNoRows, err) {
return bookish.Renter{}, ErrNotFound
}
return
}
// New inserts a record for a new renter. If that renter already exists it returns an ErrAlreadyExists.
func (s *store) New(name string, address string, email string, phoneNumber string) (id int, err error) {
q := `
INSERT INTO
renters (name, address, email, phone_number)
VALUES
(?, ?, ?, ?)
`
result, err := s.Exec(q, name, address, email, phoneNumber)
if err != nil {
if _, ok := err.(*mysql.MySQLError); ok {
return 0, ErrAlreadyExists
}
}
lastId, err := result.LastInsertId()
if err != nil {
return id, err
}
return int(lastId), nil
}
+10
View File
@@ -0,0 +1,10 @@
package bookish_server
// Renter is an entity in the Bookish API wish rents books.
type Renter struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Address string `db:"address" json:"address"`
Email string `db:"email" json:"email"`
PhoneNumber string `db:"phone_number" json:"phoneNumber"`
}
+10
View File
@@ -0,0 +1,10 @@
package renter
import (
"errors"
)
var (
ErrNotFound = errors.New("no renter found")
ErrInvalidRenter = errors.New("invalid renter provided")
)
+45
View File
@@ -0,0 +1,45 @@
package renter
import (
bookish "github.com/davidlick/bookish/bookish-server"
)
// Storage defines behavior for interacting with the renter store.
type Storage interface {
ListAll() (rr []bookish.Renter, err error)
FetchDetails(id int) (r bookish.Renter, err error)
New(name string, address string, email string, phoneNumber string) (id int, err error)
}
type service struct {
store Storage
}
// Service defines behavior for interacting with the renter service.
type Service interface {
ListRenters() (rr []bookish.Renter, err error)
FetchRenter(id int) (r bookish.Renter, err error)
RegisterRenter(fullName string, address string, email string, phoneNumber string) (id int, err error)
}
// NewService creates a new renter service.
func NewService(s Storage) *service {
return &service{
s,
}
}
// ListRenters returns all renters from the renter store.
func (s *service) ListRenters() (rr []bookish.Renter, err error) {
return s.store.ListAll()
}
// FetchRenter returns a specific renter from the renter store.
func (s *service) FetchRenter(id int) (r bookish.Renter, err error) {
return s.store.FetchDetails(id)
}
// RegisterRenter registers a new renter in the renter store.
func (s *service) RegisterRenter(fullName string, address string, email string, phoneNumber string) (id int, err error) {
return s.store.New(fullName, address, email, phoneNumber)
}
+79
View File
@@ -0,0 +1,79 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: ./renter.go
// Package renter is a generated GoMock package.
package renter
import (
bookish_server "github.com/davidlick/bookish/bookish-server"
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockStorage is a mock of Storage interface
type MockStorage struct {
ctrl *gomock.Controller
recorder *MockStorageMockRecorder
}
// MockStorageMockRecorder is the mock recorder for MockStorage
type MockStorageMockRecorder struct {
mock *MockStorage
}
// NewMockStorage creates a new mock instance
func NewMockStorage(ctrl *gomock.Controller) *MockStorage {
mock := &MockStorage{ctrl: ctrl}
mock.recorder = &MockStorageMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockStorage) EXPECT() *MockStorageMockRecorder {
return m.recorder
}
// ListAll mocks base method
func (m *MockStorage) ListAll() ([]bookish_server.Renter, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAll")
ret0, _ := ret[0].([]bookish_server.Renter)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAll indicates an expected call of ListAll
func (mr *MockStorageMockRecorder) ListAll() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockStorage)(nil).ListAll))
}
// FetchDetails mocks base method
func (m *MockStorage) FetchDetails(id int) (bookish_server.Renter, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FetchDetails", id)
ret0, _ := ret[0].(bookish_server.Renter)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FetchDetails indicates an expected call of FetchDetails
func (mr *MockStorageMockRecorder) FetchDetails(id interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchDetails", reflect.TypeOf((*MockStorage)(nil).FetchDetails), id)
}
// New mocks base method
func (m *MockStorage) New(name, address, email, phoneNumber string) (int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "New", name, address, email, phoneNumber)
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// New indicates an expected call of New
func (mr *MockStorageMockRecorder) New(name, address, email, phoneNumber interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "New", reflect.TypeOf((*MockStorage)(nil).New), name, address, email, phoneNumber)
}
+129
View File
@@ -0,0 +1,129 @@
package renter
import (
"errors"
"testing"
bookish "github.com/davidlick/bookish/bookish-server"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
func TestRenter_ListRenters(t *testing.T) {
testCases := []struct {
TestName string
Renters []bookish.Renter
Error error
}{
{
TestName: "success",
Renters: []bookish.Renter{
{
Name: "John Doe",
Address: "123 Main Way",
Email: "john@doe.com",
PhoneNumber: "5551234567",
},
{
Name: "Jack Doe",
Address: "123 Elm Street",
Email: "jack@doe.com",
PhoneNumber: "1112223333",
},
},
},
{
TestName: "error",
Error: errors.New("test error"),
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStorage := NewMockStorage(ctrl)
mockStorage.EXPECT().ListAll().Return(tc.Renters, tc.Error)
service := NewService(mockStorage)
renters, err := service.ListRenters()
assert.Equal(t, tc.Renters, renters)
assert.Equal(t, tc.Error, err)
})
}
}
func TestRenter_FetchRenter(t *testing.T) {
testCases := []struct {
TestName string
ID int
Renter bookish.Renter
Error error
}{
{
TestName: "success",
ID: 1,
Renter: bookish.Renter{
Name: "John Smith",
Address: "123 Main Way",
Email: "john@smith.com",
PhoneNumber: "5551234567",
},
},
{
TestName: "error",
ID: 1,
Error: errors.New("test error"),
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStorage := NewMockStorage(ctrl)
mockStorage.EXPECT().FetchDetails(tc.ID).Return(tc.Renter, tc.Error)
service := NewService(mockStorage)
renter, err := service.FetchRenter(tc.ID)
assert.Equal(t, tc.Renter, renter)
assert.Equal(t, tc.Error, err)
})
}
}
func TestRenter_RegisterRenter(t *testing.T) {
testCases := []struct {
TestName string
Name string
Address string
Email string
PhoneNumber string
ReturnID int
Error error
}{
{
TestName: "success",
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStorage := NewMockStorage(ctrl)
mockStorage.EXPECT().New(tc.Name, tc.Address, tc.Email, tc.PhoneNumber).Return(tc.ReturnID, tc.Error)
service := NewService(mockStorage)
id, err := service.RegisterRenter(tc.Name, tc.Address, tc.Email, tc.PhoneNumber)
assert.Equal(t, tc.ReturnID, id)
assert.Equal(t, tc.Error, err)
})
}
}
+23
View File
@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+44
View File
@@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
+13953
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "bookish-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@types/jest": "^24.9.1",
"@types/node": "^12.12.42",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1",
"typescript": "^3.7.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+38
View File
@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
+13
View File
@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+1
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+149
View File
@@ -0,0 +1,149 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
process.env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}
+5
View File
@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}
+13
View File
@@ -0,0 +1,13 @@
version: "3.1"
services:
db:
image: mysql
ports:
- 3306:3306
restart: always
volumes:
- ./init:/docker-entrypoint-initdb.d
environment:
MYSQL_DATABASE: bookish
MYSQL_ROOT_PASSWORD: 123pass
+21
View File
@@ -0,0 +1,21 @@
create table renters
(
id int auto_increment primary key,
name varchar(255) not null,
address varchar(255) not null,
email varchar(255) not null,
phone_number varchar(255) not null
);
create unique index renters_id_uindex
on renters (id);
create table rentals
(
id int auto_increment primary key,
renter_id int not null,
rental_date datetime not null,
return_date datetime null,
book_title varchar(255) not null,
foreign key (renter_id) references renters(id)
);
+1
View File
@@ -0,0 +1 @@
{"_type":"export","__export_format":4,"__export_date":"2020-05-26T04:25:17.292Z","__export_source":"insomnia.desktop.app:v7.1.1","resources":[{"_id":"req_3eae6ee43b42401baa8d6db6aa73d0dc","authentication":{},"body":{},"created":1590451686137,"description":"","headers":[],"isPrivate":false,"metaSortKey":-1590459721038,"method":"GET","modified":1590459763927,"name":"/renters","parameters":[],"parentId":"wrk_81f705d60209472ca78e799562c2a89c","settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingFollowRedirects":"global","settingRebuildPath":true,"settingSendCookies":true,"settingStoreCookies":true,"url":"{{ baseUrl }}/api/v1/renters","_type":"request"},{"_id":"wrk_81f705d60209472ca78e799562c2a89c","created":1590451640438,"description":"","modified":1590451640438,"name":"Bookish","parentId":null,"_type":"workspace"},{"_id":"req_f60f656dd8444e42a1fbabc8ceafe2e6","authentication":{},"body":{"mimeType":"application/json","text":"{\n\t\"name\": \"John Doe\",\n\t\"address\": \"123 Main Way\",\n\t\"email\": \"john@doe.com\",\n\t\"phoneNumber\": \"1112223333\"\n}"},"created":1590452476046,"description":"","headers":[{"id":"pair_b0c655af8a6b45c6a7ef4cbfb694565a","name":"Content-Type","value":"application/json"}],"isPrivate":false,"metaSortKey":-1590459721031.75,"method":"POST","modified":1590459770748,"name":"/renters","parameters":[],"parentId":"wrk_81f705d60209472ca78e799562c2a89c","settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingFollowRedirects":"global","settingRebuildPath":true,"settingSendCookies":true,"settingStoreCookies":true,"url":"{{ baseUrl }}/api/v1/renters","_type":"request"},{"_id":"req_cfda898a40b543e69946ac01530c90b4","authentication":{},"body":{},"created":1590454410123,"description":"","headers":[],"isPrivate":false,"metaSortKey":-1590459721025.5,"method":"GET","modified":1590466355071,"name":"/renters/{id}","parameters":[],"parentId":"wrk_81f705d60209472ca78e799562c2a89c","settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingFollowRedirects":"global","settingRebuildPath":true,"settingSendCookies":true,"settingStoreCookies":true,"url":"{{ baseUrl }}/api/v1/renters/3","_type":"request"},{"_id":"req_31a40e431bb74d55917133e8a4c5e298","authentication":{},"body":{"mimeType":"application/json","text":"{\n\t\"title\": \"Cheese is good\"\n}"},"created":1590459720988,"description":"","headers":[{"id":"pair_8671fae26e6c446bbabc1896360f9b68","name":"Content-Type","value":"application/json"}],"isPrivate":false,"metaSortKey":-1590459720988,"method":"POST","modified":1590461113170,"name":"/renters/{id}/books/checkout","parameters":[],"parentId":"wrk_81f705d60209472ca78e799562c2a89c","settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingFollowRedirects":"global","settingRebuildPath":true,"settingSendCookies":true,"settingStoreCookies":true,"url":"{{ baseUrl }}/api/v1/renters/1/books/checkout","_type":"request"},{"_id":"req_70a6e3eec7d548ada1003b7633768994","authentication":{},"body":{"mimeType":"application/json","text":"{\n\t\"title\": \"Cheese is good\"\n}"},"created":1590459733622,"description":"","headers":[{"id":"pair_c96a6a1e5d8a4eff81a936dd3916130a","name":"Content-Type","value":"application/json"}],"isPrivate":false,"metaSortKey":-1590457065555.5,"method":"POST","modified":1590462311877,"name":"/renters/{id}/books/return","parameters":[],"parentId":"wrk_81f705d60209472ca78e799562c2a89c","settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingFollowRedirects":"global","settingRebuildPath":true,"settingSendCookies":true,"settingStoreCookies":true,"url":"{{ baseUrl }}/api/v1/renters/1/books/return","_type":"request"},{"_id":"env_7295e57c7f153e6aceb52c051b02be6fb71c396b","color":null,"created":1590451640537,"data":{},"dataPropertyOrder":null,"isPrivate":false,"metaSortKey":1590451640537,"modified":1590451640537,"name":"Base Environment","parentId":"wrk_81f705d60209472ca78e799562c2a89c","_type":"environment"},{"_id":"jar_7295e57c7f153e6aceb52c051b02be6fb71c396b","cookies":[],"created":1590451640538,"modified":1590451640538,"name":"Default Jar","parentId":"wrk_81f705d60209472ca78e799562c2a89c","_type":"cookie_jar"},{"_id":"env_3806d3dfdedc4e96837773d594ab590c","color":null,"created":1590451647629,"data":{"baseUrl":"localhost:3000"},"dataPropertyOrder":{"&":["baseUrl"]},"isPrivate":false,"metaSortKey":1590451647629,"modified":1590451670817,"name":"Localhost","parentId":"env_7295e57c7f153e6aceb52c051b02be6fb71c396b","_type":"environment"}]}