diff --git a/controllers/album.go b/controllers/album.go deleted file mode 100644 index 2d82986..0000000 --- a/controllers/album.go +++ /dev/null @@ -1,84 +0,0 @@ -package controllers - -import ( - "net/http" - "strconv" - "web-service-gin/models" - - "github.com/gin-gonic/gin" -) - -func FindAlbums(c *gin.Context) { - var albums []models.Album - models.DB.Find(&albums) - - c.JSON(http.StatusOK, gin.H{"data": albums}) -} - -func FindAlbum(c *gin.Context) { - var album models.Album - - if err := models.DB.Where("id = ?", c.Param("id")).First(&album).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Record not found!"}) - } - - c.JSON(http.StatusOK, gin.H{"data": album}) -} - -func CreateAlbum(c *gin.Context) { - var input models.CreateAlbumInput - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var price float64 - if f, err := strconv.ParseFloat(input.Price, 64); err == nil { - price = f - } - - album := models.Album{ - Title: input.Title, - Artist: input.Artist, - Price: price, - } - models.DB.Create(&album) - c.JSON(http.StatusOK, gin.H{"data": album}) -} - -func UpdateAlbum(c *gin.Context) { - var album models.Album - if err := models.DB.Where("id = ?", c.Param("id")).First(&album).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Record not found!"}) - } - - var input models.UpdateAlbumInput - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - models.DB.Model(&album).Updates(input) - c.JSON(http.StatusOK, gin.H{"data": album}) -} - -func DeleteAlbum(c *gin.Context) { - var album models.Album - if err := models.DB.Where("id = ?", c.Param("id")).First(&album).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Record not found!"}) - return - } - - models.DB.Delete(&album) - - c.JSON(http.StatusOK, gin.H{"data": true}) -} - -func ApplyAlbumRouter(router *gin.Engine) *gin.Engine { - router.GET("/albums", FindAlbums) - router.POST("/albums", CreateAlbum) - router.GET("/albums/:id", FindAlbum) - router.PATCH("/albums/:id", UpdateAlbum) - router.DELETE("/albums/:id", DeleteAlbum) - return router -} diff --git a/go.mod b/go.mod index 81d8337..b3de5b8 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,9 @@ go 1.19 require ( github.com/gin-gonic/gin v1.8.1 - gorm.io/driver/mysql v1.4.1 - gorm.io/gorm v1.24.0 + github.com/go-sql-driver/mysql v1.6.0 + github.com/guregu/null v4.0.0+incompatible + github.com/jmoiron/sqlx v1.3.5 ) require ( @@ -13,10 +14,7 @@ require ( github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/validator/v10 v10.10.0 // indirect - github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/goccy/go-json v0.9.7 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mattn/go-isatty v0.0.14 // indirect diff --git a/go.sum b/go.sum index 45621d2..41f1114 100644 --- a/go.sum +++ b/go.sum @@ -22,11 +22,10 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw= +github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -39,8 +38,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -91,8 +94,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.4.1 h1:4InA6SOaYtt4yYpV1NF9B2kvUKe9TbvUd1iWrvxnjic= -gorm.io/driver/mysql v1.4.1/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= -gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= -gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= -gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= diff --git a/internal/controllers/album.go b/internal/controllers/album.go new file mode 100644 index 0000000..d209e6f --- /dev/null +++ b/internal/controllers/album.go @@ -0,0 +1,177 @@ +package controllers + +import ( + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "net/http" + "web-service-gin/internal/forms" + models2 "web-service-gin/internal/models" +) + +// Album provides the handlers for the album entity. +type Album struct { + albumService models2.AlbumService +} + +// NewAlbum creates the controller using the given data mapper for +// albums. +func NewAlbum(albumService models2.AlbumService) *Album { + return &Album{ + albumService: albumService, + } +} + +// Post will create a new album from the given data, if the form is valid. +func (p *Album) Post(c *gin.Context) { + var form forms.CreateAlbum + if c.ShouldBindWith(&form, binding.JSON) != nil { + // TODO: Give a better error message. + c.JSON( + http.StatusNotAcceptable, + gin.H{"message": "invalid data."}, + ) + c.Abort() + return + } + + album, err := p.albumService.Create(&form) + if err != nil { + // TODO: An error middleware should log the error, + // and email admin. + c.Error(err) + c.JSON( + http.StatusInternalServerError, + gin.H{"message": "internal error"}, + ) + c.Abort() + return + } + + // TODO: use a view if part of the album data should not be + // returned to the client. + c.JSON( + http.StatusCreated, + album, + ) +} + +// Put will perform an update of a album. +func (p *Album) Put(c *gin.Context) { + var form forms.CreateAlbum + if err := c.ShouldBindWith(&form, binding.JSON); err != nil { + // TODO: Give a better error message. + c.JSON( + http.StatusNotAcceptable, + gin.H{ + "message": "invalid data.", + "form": form, + "error": err.Error(), + }, + ) + c.Abort() + return + } + id := c.Param("id") + + album, err := p.albumService.GetByID(id) + if err == models2.ErrNotFound { + c.JSON( + http.StatusNotFound, + gin.H{"message": "user not found"}, + ) + c.Abort() + return + } else if err != nil { + c.Error(err) + c.JSON( + http.StatusInternalServerError, + gin.H{"message": "internal error."}, + ) + c.Abort() + return + } + + album.ApplyForm(&form) + err = p.albumService.Update(album) + if err != nil { + c.Error(err) + c.JSON( + http.StatusInternalServerError, + gin.H{"message": "internal error."}, + ) + c.Abort() + return + } + + c.JSON( + http.StatusOK, + gin.H{"message": "updated"}, + ) +} + +// Get will fetch an album by ID. +func (p *Album) Get(c *gin.Context) { + id := c.Param("id") + album, err := p.albumService.GetByID(id) + if err == models2.ErrNotFound { + c.JSON( + http.StatusNotFound, + gin.H{"message": "user not found"}, + ) + c.Abort() + return + } else if err != nil { + c.Error(err) + c.JSON( + http.StatusInternalServerError, + gin.H{"message": "internal error."}, + ) + c.Abort() + return + } + + c.JSON( + http.StatusOK, + album, + ) +} + +// GetAll will fetch all Albums. +// TODO: Pagination +func (p *Album) GetAll(c *gin.Context) { + albums, err := p.albumService.GetAll() + if err != nil { + c.Error(err) + c.JSON( + http.StatusInternalServerError, + gin.H{"message": "internal error."}, + ) + c.Abort() + return + } + c.JSON( + http.StatusOK, + albums, + ) +} + +// Delete will remove a album from the DB. +func (p *Album) Delete(c *gin.Context) { + id := c.Param("id") + + err := p.albumService.Delete(id) + + if err != nil { + c.Error(err) + c.JSON( + http.StatusInternalServerError, + gin.H{"message": "internal error."}, + ) + c.Abort() + } + + c.JSON( + http.StatusOK, + gin.H{"message": "deleted"}, + ) +} diff --git a/internal/forms/album.go b/internal/forms/album.go new file mode 100644 index 0000000..203a6f8 --- /dev/null +++ b/internal/forms/album.go @@ -0,0 +1,12 @@ +package forms + +type CreateAlbum struct { + ID *int64 `form:"id" json:"id" binding:"required"` + Title *string `form:"title" json:"title" binding:"required"` + Artist *string `form:"artist" json:"artist" binding:"required"` + Price *float64 `form:"price" json:"price" binding:"required"` + + CreatedAt *string `form:"created_at" json:"created_at"` + UpdatedAt *string `form:"updated_at" json:"updated_at"` + DeletedAt *string `form:"deleted_at" json:"deleted_at"` +} diff --git a/internal/models/album.go b/internal/models/album.go new file mode 100644 index 0000000..0bca18d --- /dev/null +++ b/internal/models/album.go @@ -0,0 +1,34 @@ +package models + +import ( + "github.com/guregu/null" + "web-service-gin/internal/forms" +) + +type Album struct { + ID int64 `db:"id"` + CreatedAt null.String `db:"created_at"` + UpdatedAt null.String `db:"updated_at"` + DeletedAt null.String `db:"deleted_at"` + Title string `db:"title"` + Artist string `db:"artist"` + Price float64 `db:"price"` +} + +func (a *Album) ApplyForm(form *forms.CreateAlbum) { + a.ID = *form.ID + a.Title = *form.Title + a.Artist = *form.Artist + a.Price = *form.Price + a.CreatedAt = null.StringFromPtr(form.CreatedAt) + a.UpdatedAt = null.StringFromPtr(form.UpdatedAt) + a.DeletedAt = null.StringFromPtr(form.DeletedAt) +} + +type AlbumService interface { + Create(*forms.CreateAlbum) (*Album, error) + GetByID(string) (*Album, error) + GetAll() (*[]Album, error) + Update(*Album) error + Delete(string) error +} diff --git a/internal/models/errors.go b/internal/models/errors.go new file mode 100644 index 0000000..d9f6af5 --- /dev/null +++ b/internal/models/errors.go @@ -0,0 +1,5 @@ +package models + +import "errors" + +var ErrNotFound = errors.New("object not found") diff --git a/internal/models/sql/album.go b/internal/models/sql/album.go new file mode 100644 index 0000000..76df053 --- /dev/null +++ b/internal/models/sql/album.go @@ -0,0 +1,148 @@ +package sql + +import ( + "database/sql" + "github.com/jmoiron/sqlx" + "web-service-gin/internal/forms" + models2 "web-service-gin/internal/models" +) + +// AlbumService is the implementation of the album data mapping layer +// using SQL. +type AlbumService struct { + conn *sqlx.DB +} + +// NewAlbumService creates the album service using the given +// connection pool to a mysql DB. +func NewAlbumService(conn *sqlx.DB) (*AlbumService, error) { + // TODO: It would be better to use a DB management tool + // to make migrations painless. + + // _, err := conn.Exec(` + //CREATE TABLE albums ( + // id bigint NOT NULL AUTO_INCREMENT, + // title longtext, + // artist longtext, + // price double DEFAULT NULL, + // PRIMARY KEY (id), + // KEY idx_albums_deleted_at (deleted_at) + //) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + //`) + // if err != nil { + // return nil, err + // } + return &AlbumService{conn: conn}, nil +} + +// Create will try to add the album to the DB. +func (s *AlbumService) Create(form *forms.CreateAlbum) (*models2.Album, error) { + q := ` +INSERT INTO albums(title, artist, price) +VALUES (?, ?, ?) +RETURNING *;` + + var output models2.Album + err := s.conn.Get( + &output, + q, + *form.Title, + *form.Artist, + *form.Price, + ) + + if err != nil { + return nil, err + } + return &output, nil +} + +// Update will replace the values of the give album with those provided. +func (s *AlbumService) Update(p *models2.Album) error { + q := ` +UPDATE albums +SET updated_at = NOW(), + title = ?, + artist = ?, + price = ? +WHERE id = ?; +` + + _, err := s.conn.Exec( + q, + p.Title, + p.Artist, + p.Price, + p.ID, + ) + if err != nil { + return err + } + return nil +} + +// GetByID fetches the album with the given id. +func (s *AlbumService) GetByID(id string) (*models2.Album, error) { + if !validID(id) { + return nil, models2.ErrNotFound + } + + q := ` +SELECT * +FROM albums +WHERE id = ?;` + + var output models2.Album + err := s.conn.Get( + &output, + q, + id, + ) + // Replace the SQL error with our own error type. + if err == sql.ErrNoRows { + return nil, models2.ErrNotFound + } else if err != nil { + return nil, err + } else { + return &output, nil + } +} + +// GetAll fetches all albums. +func (s *AlbumService) GetAll() (*[]models2.Album, error) { + q := `SELECT * FROM albums;` + + var output []models2.Album + err := s.conn.Select(&output, q) + // Replace the SQL error with our own error type. + if err == sql.ErrNoRows { + return nil, models2.ErrNotFound + } else if err != nil { + return nil, err + } else { + return &output, nil + } +} + +// Delete removes the album with the given id from the DB. +// TODO: this should just mark the object as deleted, +// not actually get rid of the data. +func (s *AlbumService) Delete(id string) error { + if !validID(id) { + return models2.ErrNotFound + } + + q := ` +DELETE FROM albums +WHERE id = ?; +` + + _, err := s.conn.Exec( + q, + id, + ) + return err +} + +// Check it implements the interface +var _ models2.AlbumService = &AlbumService{} diff --git a/internal/models/sql/sql.go b/internal/models/sql/sql.go new file mode 100644 index 0000000..8d4a95b --- /dev/null +++ b/internal/models/sql/sql.go @@ -0,0 +1,36 @@ +package sql + +import ( + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + "strconv" +) + +// NewSQL creates and SQL connection using environment variables +// to configure. +func NewSQL() (*sqlx.DB, error) { + //host := strings.TrimSpace(os.Getenv("MYSQL_HOST")) + //port := strings.TrimSpace(os.Getenv("MYSQL_PORT")) + //user := strings.TrimSpace(os.Getenv("MYSQL_USER")) + //password := strings.TrimSpace(os.Getenv("MYSQL_PASSWORD")) + //db := strings.TrimSpace(os.Getenv("MYSQL_DB")) + // + //info := fmt.Sprintf( + // "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + // host, + // port, + // user, + // password, + // db, + //) + return sqlx.Connect( + "mysql", + "mysql:password@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local", + ) +} + +// validID checks if the given string is a valid id. +func validID(id string) bool { + _, err := strconv.Atoi(id) + return err == nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..d011c2c --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,45 @@ +package server + +import ( + "github.com/gin-gonic/gin" + "web-service-gin/internal/controllers" + "web-service-gin/internal/models" + "web-service-gin/internal/models/sql" +) + +// Server represents all the services and controllers. +type Server struct { + AlbumService models.AlbumService + Gin *gin.Engine +} + +// NewServer creates a new server using environment variables to +// configure DB connection. +func NewServer() (*Server, error) { + db, err := sql.NewSQL() + if err != nil { + return nil, err + } + + albumService, err := sql.NewAlbumService(db) + if err != nil { + return nil, err + } + + r := gin.Default() + { + route := r.Group("/albums") + ctrl := controllers.NewAlbum(albumService) + + route.GET("", ctrl.GetAll) + route.POST("", ctrl.Post) + route.PUT("/:id", ctrl.Put) + route.GET("/:id", ctrl.Get) + route.DELETE("/:id", ctrl.Delete) + } + + return &Server{ + AlbumService: albumService, + Gin: r, + }, nil +} diff --git a/main.go b/main.go index 593ebe5..c646a27 100644 --- a/main.go +++ b/main.go @@ -1,34 +1,17 @@ package main +import "web-service-gin/internal/server" + +// Initial based on: // https://go.dev/doc/tutorial/web-service-gin // https://blog.logrocket.com/how-to-build-a-rest-api-with-golang-using-gin-and-gorm/ - -import ( - "net/http" - "web-service-gin/controllers" - "web-service-gin/models" - - "github.com/gin-gonic/gin" -) - -func SetupRouter() *gin.Engine { - router := gin.Default() - router.GET("/ping", func(c *gin.Context) { - c.JSON(200, gin.H{"message": "pong"}) - }) - router = controllers.ApplyAlbumRouter(router) - - return router -} +// Then moved to Sqlx +// https://github.com/wetterj/gin-sqlx-crud func main() { - router := SetupRouter() - - models.ConnectDatabase() - - s := &http.Server{ - Addr: ":8123", - Handler: router, + server, err := server.NewServer() + if err != nil { + panic(err) } - s.ListenAndServe() + server.Gin.Run() } diff --git a/models/album.go b/models/album.go deleted file mode 100644 index 30e0e42..0000000 --- a/models/album.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -type Album struct { - ID string `json:"id" gorm:"primary_key"` - Title string `json:"title"` - Artist string `json:"artist"` - Price float64 `json:"price"` -} - -type CreateAlbumInput struct { - Title string `json:"title" binding:"required"` - Artist string `json:"artist" binding:"required"` - Price string `json:"price" binding:"required"` -} - -type UpdateAlbumInput struct { - Title string `json:"title"` - Artist string `json:"artist"` - Price float64 `json:"price"` -} diff --git a/models/models.go b/models/models.go deleted file mode 100644 index 8a730a4..0000000 --- a/models/models.go +++ /dev/null @@ -1,20 +0,0 @@ -package models - -import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -var DB *gorm.DB - -func ConnectDatabase() { - // Obviously change this to your settings and get securely. - dsn := "mysql:password@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - panic("Failed to connect to database") - } - _ = db.AutoMigrate(&Album{}) - - DB = db -} diff --git a/test.http b/test.http index df098e4..aa1986f 100644 --- a/test.http +++ b/test.http @@ -1,31 +1,31 @@ -GET http://localhost:8123/albums/4 +GET http://localhost:8080/albums/4 Content-Type: application/json ### -GET http://localhost:8123/albums +GET http://localhost:8080/albums Content-Type: application/json ### -POST http://localhost:8123/albums +POST http://localhost:8080/albums Content-Type: application/json {"title": "Mark Tom and Travis Show", "artist": "Blink-182", "price": "18.99"} ### -POST http://localhost:8123/albums +POST http://localhost:8080/albums Content-Type: application/json {"title": "Blue Train", "artist": "John Coltrane", "price": 56.99} ### -POST http://localhost:8123/albums +POST http://localhost:8080/albums Content-Type: application/json {"title": "Jeru", "artist": "Gerry Mulligan", "price": 56.99} ### -POST http://localhost:8123/albums +POST http://localhost:8080/albums Content-Type: application/json {"title": "Sarah Vaughan and Clifford Brown", "artist": "Sarah Vaughan", "price": 39.99}