diff --git a/Makefile b/Makefile index 3af9f6e..e9e1895 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,7 @@ generatemocks: cd $(CURDIR)/pkg/maverick && minimock -i IMaverickClient -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/service/incident_jira && minimock -i IncidentJiraService -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/model/incident_jira && minimock -i IncidentJiraRepository -s _mock.go -o $(CURDIR)/mocks + cd $(CURDIR)/model/product && minimock -i ProductRepository -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/service/google && minimock -i IDriveService -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/service/rca && minimock -i IRcaService -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/pkg/alertClient && minimock -i AlertClient -s _mock.go -o $(CURDIR)/mocks diff --git a/appcontext/app.go b/appcontext/app.go index 413632f..656749c 100644 --- a/appcontext/app.go +++ b/appcontext/app.go @@ -6,6 +6,7 @@ import ( "gorm.io/gorm" "houston/model/incident" "houston/model/log" + productModel "houston/model/product" "houston/model/severity" "houston/model/tag" "houston/model/team" @@ -20,6 +21,7 @@ import ( "houston/service/documentService" "houston/service/google" incidentService "houston/service/incident/impl" + "houston/service/products" rcaService "houston/service/rca/impl" "houston/service/slack" tagService "houston/service/tag" @@ -45,6 +47,7 @@ type houstonServices struct { driveService google.IDriveService calendarService *conference.CalendarService tagService *tagService.TagService + productsService products.ProductService } var appContext *applicationContext @@ -76,6 +79,7 @@ func InitializeServices() { driveService: initDriveService(), calendarService: initCalendarService(), tagService: initTagService(), + productsService: initProductsService(), } } @@ -156,6 +160,14 @@ func GetTagService() *tagService.TagService { return services.tagService } +func initProductsService() products.ProductService { + return products.NewProductService(productModel.NewProductRepo(GetDB())) +} + +func GetProductsService() products.ProductService { + return services.productsService +} + func GetCalendarService() *conference.CalendarService { return services.calendarService } diff --git a/cmd/app/handler/product_handler.go b/cmd/app/handler/product_handler.go new file mode 100644 index 0000000..c271292 --- /dev/null +++ b/cmd/app/handler/product_handler.go @@ -0,0 +1,153 @@ +package handler + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "houston/appcontext" + stringUtil "houston/common/util/string" + "houston/logger" + productModel "houston/model/product" + "houston/service/products" + productRequest "houston/service/request/product" + common "houston/service/response/common" + "net/http" + "strings" +) + +const ( + productHandlerLogTag = "[product_handler]" +) + +type ProductHandler struct { + gin *gin.Engine + service products.ProductService +} + +func NewProductHandler( + gin *gin.Engine, +) *ProductHandler { + return &ProductHandler{ + gin: gin, + service: appcontext.GetProductsService(), + } +} + +func (handler *ProductHandler) HandleCreateProduct(c *gin.Context) { + var createProductRequest productRequest.CreateOrUpdatedProductRequest + if err := c.ShouldBindJSON(&createProductRequest); err != nil { + c.JSON( + http.StatusInternalServerError, + common.ErrorResponse( + errors.New("failed to process the request"), http.StatusInternalServerError, nil, + ), + ) + return + } + + if err := validateCreateOrProductRequest(createProductRequest); err != nil { + logger.Error(fmt.Sprintf("%s received invalid request to create new product. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + createdProductIds, err := handler.service.CreateProduct(createProductRequest.ProductName) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to create product. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse(createdProductIds, http.StatusOK)) +} + +func (handler *ProductHandler) HandleGetAllProducts(c *gin.Context) { + productDTOs, err := handler.service.GetAllProducts() + if err != nil { + logger.Error(fmt.Sprintf("%s failed to get all products. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse(productDTOs, http.StatusOK)) +} + +func (handler *ProductHandler) HandleGetProductByID(c *gin.Context) { + productId, err := getProductIdFromPath(c) + if err != nil { + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + productDTO, err := handler.service.GetProductByID(productId) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to get product by ID. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse(productDTO, http.StatusOK)) +} + +func (handler *ProductHandler) HandleUpdateProduct(c *gin.Context) { + var updateProductRequest productRequest.CreateOrUpdatedProductRequest + if err := c.ShouldBindJSON(&updateProductRequest); err != nil { + c.JSON( + http.StatusInternalServerError, + common.ErrorResponse( + errors.New("failed to process the request"), http.StatusInternalServerError, nil, + ), + ) + return + } + + if err := validateCreateOrProductRequest(updateProductRequest); err != nil { + logger.Error(fmt.Sprintf("%s received invalid request to update product. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + + productId, err := getProductIdFromPath(c) + if err != nil { + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + productDTO := &productModel.ProductDTO{ + ProductID: productId, + ProductName: updateProductRequest.ProductName, + } + err = handler.service.UpdateProduct(productDTO) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to update product. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse(nil, http.StatusOK)) +} + +func (handler *ProductHandler) HandleDeleteProductByID(c *gin.Context) { + productId, err := getProductIdFromPath(c) + if err != nil { + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + err = handler.service.DeleteProductByID(productId) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to delete product. %+v", productHandlerLogTag, err)) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse(nil, http.StatusOK)) +} + +func validateCreateOrProductRequest(request productRequest.CreateOrUpdatedProductRequest) error { + if strings.TrimSpace(request.ProductName) == "" { + return errors.New("product name can not be empty") + } + return nil +} + +func getProductIdFromPath(c *gin.Context) (uint, error) { + productIdString := c.Param("id") + productId, err := stringUtil.StringToUint(productIdString) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to convert product id to uint. %+v", productHandlerLogTag, err)) + return 0, fmt.Errorf("%s is not a valid product ID", productIdString) + } + return productId, nil +} diff --git a/cmd/app/server.go b/cmd/app/server.go index 8b8f8b9..6c711fe 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -55,6 +55,7 @@ func (s *Server) Handler(houstonGroup *gin.RouterGroup) { s.rcaHandler(houstonGroup) s.gin.Use(s.createMiddleware()) s.incidentClientHandlerV2(houstonGroup) + s.productsHandler(houstonGroup) s.teamHandler(houstonGroup) s.severityHandler(houstonGroup) s.incidentHandler(houstonGroup) @@ -142,6 +143,16 @@ func (s *Server) incidentClientHandler(houstonGroup *gin.RouterGroup) { houstonGroup.POST("/create-incident", incidentHandler.CreateIncident) } +func (s *Server) productsHandler(houstonGroup *gin.RouterGroup) { + productsHandler := handler.NewProductHandler(s.gin) + + houstonGroup.POST("/products", s.authService.IfAdmin(productsHandler.HandleCreateProduct)) + houstonGroup.PUT("/product/:id", s.authService.IfAdmin(productsHandler.HandleUpdateProduct)) + houstonGroup.DELETE("/product/:id", s.authService.IfAdmin(productsHandler.HandleDeleteProductByID)) + houstonGroup.GET("/products", productsHandler.HandleGetAllProducts) + houstonGroup.GET("/product/:id", productsHandler.HandleGetProductByID) +} + func (s *Server) incidentClientHandlerV2(houstonGroup *gin.RouterGroup) { houstonGroup.Use(func(c *gin.Context) { // Add your desired header key-value pair diff --git a/common/util/string/string_util.go b/common/util/string/string_util.go index 3c2548e..0b7d54f 100644 --- a/common/util/string/string_util.go +++ b/common/util/string/string_util.go @@ -1,6 +1,10 @@ package string -import "strings" +import ( + "fmt" + "strconv" + "strings" +) func IsBlank(input string) bool { trimmedInput := strings.TrimSpace(input) @@ -19,3 +23,11 @@ func ExtractCommaSeparatedTrimmedArrayFromString(jiraText string) []string { return stringArray } + +func StringToUint(input string) (uint, error) { + u64, err := strconv.ParseUint(input, 10, 32) + if err != nil { + return 0, fmt.Errorf("failed to convert string to uint: %s", err) + } + return uint(u64), nil +} diff --git a/db/migration/000014_product_table.up.sql b/db/migration/000014_product_table.up.sql new file mode 100644 index 0000000..5cdbf20 --- /dev/null +++ b/db/migration/000014_product_table.up.sql @@ -0,0 +1,15 @@ +-- Table Definition ---------------------------------------------- + +CREATE TABLE product +( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL UNIQUE, + created_at timestamp with time zone, + updated_at timestamp with time zone +); + +-- Indices ------------------------------------------------------- + +CREATE UNIQUE INDEX product_pkey ON product (id int8_ops); +CREATE UNIQUE INDEX product_name_key ON product (name text_ops); +CREATE INDEX idx_product_name ON product (name text_ops); \ No newline at end of file diff --git a/go.mod b/go.mod index 57ecc33..f92edd2 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,6 @@ require ( github.com/aws/aws-sdk-go v1.44.262 github.com/aws/aws-sdk-go-v2/config v1.18.25 github.com/aws/aws-sdk-go-v2/service/s3 v1.33.1 - github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 - github.com/chromedp/chromedp v0.9.1 github.com/gin-contrib/zap v0.1.0 github.com/gin-gonic/gin v1.9.1 github.com/gojuno/minimock/v3 v3.1.3 @@ -51,20 +49,14 @@ require ( github.com/aws/smithy-go v1.13.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/chromedp/sysutil v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/gobwas/httphead v0.1.0 // indirect - github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/s2a-go v0.1.7 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect @@ -85,7 +77,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/goccy/go-json v0.10.2 + github.com/goccy/go-json v0.10.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/model/product/entity.go b/model/product/entity.go new file mode 100644 index 0000000..a9a82ba --- /dev/null +++ b/model/product/entity.go @@ -0,0 +1,16 @@ +package product + +import ( + "time" +) + +type ProductEntity struct { + ID uint `gorm:"primarykey;column:id;autoIncrement"` + Name string `gorm:"column:name;not null;unique;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;default:null"` +} + +func (ProductEntity) TableName() string { + return "product" +} diff --git a/model/product/model.go b/model/product/model.go new file mode 100644 index 0000000..fc8a638 --- /dev/null +++ b/model/product/model.go @@ -0,0 +1,6 @@ +package product + +type ProductDTO struct { + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` +} diff --git a/model/product/product_repository.go b/model/product/product_repository.go new file mode 100644 index 0000000..87907e0 --- /dev/null +++ b/model/product/product_repository.go @@ -0,0 +1,19 @@ +package product + +import ( + "gorm.io/gorm" +) + +type ProductRepository interface { + InsertProduct(productName string) (uint, error) + UpdateProduct(productEntity *ProductEntity) error + GetAllProducts() ([]ProductEntity, error) + GetProductById(productID uint) (*ProductEntity, error) + GetProductByName(productName string) (*ProductEntity, error) + DeleteProductByID(productID uint) error + DeleteProductByName(productName string) error +} + +func NewProductRepo(db *gorm.DB) ProductRepository { + return &productRepositoryImpl{db: db} +} diff --git a/model/product/product_repository_impl.go b/model/product/product_repository_impl.go new file mode 100644 index 0000000..c53b12c --- /dev/null +++ b/model/product/product_repository_impl.go @@ -0,0 +1,85 @@ +package product + +import ( + "gorm.io/gorm" +) + +type productRepositoryImpl struct { + db *gorm.DB +} + +func (repo *productRepositoryImpl) InsertProduct(productName string) (uint, error) { + var record = ProductEntity{ + Name: productName, + } + + result := repo.db.Create(&record) + if result.Error != nil { + return 0, result.Error + } + + return record.ID, nil +} + +func (repo *productRepositoryImpl) UpdateProduct(productEntity *ProductEntity) error { + result := repo.db.Save(productEntity) + if result.Error != nil { + return result.Error + } + return nil +} + +func (repo *productRepositoryImpl) GetAllProducts() ([]ProductEntity, error) { + var entities []ProductEntity + + result := repo.db.Find(&entities) + if result.Error != nil { + return nil, result.Error + } + + if result.RowsAffected == 0 { + return nil, nil + } + + return entities, nil +} + +func (repo *productRepositoryImpl) GetProductById(productID uint) (*ProductEntity, error) { + var entity ProductEntity + result := repo.db.First(&entity, "id = ?", productID) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, nil + } + return &entity, nil +} + +func (repo *productRepositoryImpl) GetProductByName(productName string) (*ProductEntity, error) { + var entity ProductEntity + result := repo.db.First(&entity, "name = ?", productName) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, nil + } + return &entity, nil +} + +func (repo *productRepositoryImpl) DeleteProductByID(productID uint) error { + result := repo.db.Delete(&ProductEntity{}, "id = ?", productID) + if result.Error != nil { + return result.Error + } + return nil +} + +func (repo *productRepositoryImpl) DeleteProductByName(productName string) error { + result := repo.db.Delete(&ProductEntity{}, "name = ?", productName) + if result.Error != nil { + return result.Error + } + return nil +} diff --git a/service/products/product_service.go b/service/products/product_service.go new file mode 100644 index 0000000..22630ff --- /dev/null +++ b/service/products/product_service.go @@ -0,0 +1,19 @@ +package products + +import ( + productModel "houston/model/product" +) + +type ProductService interface { + CreateProduct(productName string) (uint, error) + UpdateProduct(productDTO *productModel.ProductDTO) error + GetAllProducts() ([]productModel.ProductDTO, error) + GetProductByID(productID uint) (*productModel.ProductDTO, error) + GetProductByName(productName string) (*productModel.ProductDTO, error) + DeleteProductByID(productID uint) error + DeleteProductByName(productName string) error +} + +func NewProductService(repo productModel.ProductRepository) ProductService { + return &productServiceImpl{repo} +} diff --git a/service/products/product_service_impl.go b/service/products/product_service_impl.go new file mode 100644 index 0000000..52657d0 --- /dev/null +++ b/service/products/product_service_impl.go @@ -0,0 +1,71 @@ +package products + +import ( + "errors" + productModel "houston/model/product" +) + +type productServiceImpl struct { + repo productModel.ProductRepository +} + +func (service *productServiceImpl) CreateProduct(productName string) (uint, error) { + existingProducts, err := service.repo.GetProductByName(productName) + if err != nil { + return 0, errors.New("error while processing the request") + } + if existingProducts != nil { + return 0, errors.New("product already exists") + } + return service.repo.InsertProduct(productName) +} + +func (service *productServiceImpl) UpdateProduct(productDTO *productModel.ProductDTO) error { + entity, err := service.repo.GetProductById(productDTO.ProductID) + if err != nil { + return err + } + entity.Name = productDTO.ProductName + return service.repo.UpdateProduct(entity) +} + +func (service *productServiceImpl) GetAllProducts() ([]productModel.ProductDTO, error) { + productEntities, err := service.repo.GetAllProducts() + if err != nil { + return nil, err + } + var productDTOs []productModel.ProductDTO + for _, entity := range productEntities { + productDTOs = append(productDTOs, productModel.ProductDTO{ + ProductID: entity.ID, + ProductName: entity.Name, + }) + } + return productDTOs, nil +} +func (service *productServiceImpl) GetProductByID(productID uint) (*productModel.ProductDTO, error) { + productEntity, err := service.repo.GetProductById(productID) + if err != nil { + return nil, err + } + return &productModel.ProductDTO{ + ProductID: productEntity.ID, + ProductName: productEntity.Name, + }, nil +} +func (service *productServiceImpl) GetProductByName(productName string) (*productModel.ProductDTO, error) { + productEntity, err := service.repo.GetProductByName(productName) + if err != nil { + return nil, err + } + return &productModel.ProductDTO{ + ProductID: productEntity.ID, + ProductName: productEntity.Name, + }, nil +} +func (service *productServiceImpl) DeleteProductByID(productID uint) error { + return service.repo.DeleteProductByID(productID) +} +func (service *productServiceImpl) DeleteProductByName(productName string) error { + return service.repo.DeleteProductByName(productName) +} diff --git a/service/products/product_service_test.go b/service/products/product_service_test.go new file mode 100644 index 0000000..7a11d87 --- /dev/null +++ b/service/products/product_service_test.go @@ -0,0 +1,115 @@ +package products + +import ( + "errors" + "github.com/gojuno/minimock/v3" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" + "houston/logger" + "houston/mocks" + repo "houston/model/product" + "testing" +) + +type ProductServiceSuite struct { + suite.Suite + controller *minimock.Controller + repoMock *mocks.ProductRepositoryMock + service ProductService +} + +func (suite *ProductServiceSuite) SetupTest() { + logger.InitLogger() + viper.Set("jira.link.max.length", 50) + suite.controller = minimock.NewController(suite.T()) + suite.T().Cleanup(suite.controller.Finish) + suite.repoMock = mocks.NewProductRepositoryMock(suite.controller) + suite.service = NewProductService(suite.repoMock) +} + +func TestProductService(t *testing.T) { + suite.Run(t, new(ProductServiceSuite)) +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_CreateProduct() { + suite.repoMock.GetProductByNameMock.Return(nil, nil) + suite.repoMock.InsertProductMock.When("test").Then(1, nil) + + _, err := suite.service.CreateProduct("test") + if err != nil { + suite.Fail("Create Product Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_UpdateProduct() { + suite.repoMock.GetProductByIdMock.When(uint(1)).Then(&repo.ProductEntity{}, nil) + suite.repoMock.UpdateProductMock.Return(nil) + + err := suite.service.UpdateProduct(&repo.ProductDTO{ProductID: 1, ProductName: "test"}) + if err != nil { + suite.Fail("Update Product Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_UpdateProductWithInvalidID() { + suite.repoMock.GetProductByIdMock.When(uint(1)).Then(nil, errors.New("invalid ID")) + + err := suite.service.UpdateProduct(&repo.ProductDTO{ProductID: 1, ProductName: "test"}) + if err == nil { + suite.Fail("Update Product with invalid ID Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_GetAllProducts() { + suite.repoMock.GetAllProductsMock.Return(&[]repo.ProductEntity{}, nil) + + _, err := suite.service.GetAllProducts() + if err != nil { + suite.Fail("Get All Products Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_GetProductByID() { + suite.repoMock.GetProductByIdMock.When(uint(1)).Then(&repo.ProductEntity{}, nil) + + _, err := suite.service.GetProductByID(1) + if err != nil { + suite.Fail("Get Product By ID Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_DeleteProductByID() { + suite.repoMock.DeleteProductByIDMock.When(uint(1)).Then(nil) + + err := suite.service.DeleteProductByID(1) + if err != nil { + suite.Fail("Delete Product By ID Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_DeleteProductByName() { + suite.repoMock.DeleteProductByNameMock.When("test").Then(nil) + + err := suite.service.DeleteProductByName("test") + if err != nil { + suite.Fail("Delete Product By Name Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_GetProductByName() { + suite.repoMock.GetProductByNameMock.When("test").Then(&repo.ProductEntity{}, nil) + + _, err := suite.service.GetProductByName("test") + if err != nil { + suite.Fail("Get Product By Name Failed", err) + } +} + +func (suite *ProductServiceSuite) TestProductServiceImpl_CreateProduct_ExistingProduct() { + suite.repoMock.GetProductByNameMock.Return(&repo.ProductEntity{ID: 1, Name: "test"}, nil) + + _, err := suite.service.CreateProduct("test") + if err == nil { + suite.Fail("Create Product Failed", err) + } +} diff --git a/service/request/product/create_and_update_product.go b/service/request/product/create_and_update_product.go new file mode 100644 index 0000000..1826251 --- /dev/null +++ b/service/request/product/create_and_update_product.go @@ -0,0 +1,5 @@ +package product + +type CreateOrUpdatedProductRequest struct { + ProductName string `json:"productName"` +}