Merge pull request 'feature/product-app list api' (#296) from feature/product-app into develop

Reviewed-on: ebhomengo/niki#296
This commit is contained in:
hossein 2026-04-29 05:06:52 +00:00
commit 5a05490502
20 changed files with 517 additions and 75 deletions

34
.air/.air.productapp.toml Normal file
View File

@ -0,0 +1,34 @@
root = "."
tmp_dir = "cmd/productapp/temp"
[build]
bin = "/entrypoint.sh"
args_bin = []
cmd = "go build -mod=mod -buildvcs=false -o ./cmd/productapp/temp/main ./cmd/productapp/"
delay = 1000
exclude_dir = ["vendor", "cmd/productapp/temp"]
exclude_file = []
exclude_regex = []
exclude_unchanged = false
follow_symlink = false
include_dir = []
include_ext = ["go"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
poll = true
poll_interval = 1000
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false

2
.gitignore vendored
View File

@ -30,3 +30,5 @@ logs/
mise.log mise.log
curl curl
cmd/**/temp/main

124
Makefile
View File

@ -1,54 +1,64 @@
# TODO: add commands for build and run in dev/produciton mode # --- Variables ---
BINARY_NAME ?= niki
BUILD_DIR ?= bin
ROOT=$(realpath $(dir $(lastword $(MAKEFILE_LIST)))) # ====================================================================================
# General Go Commands
# ====================================================================================
.PHONY: start test build clean mod-tidy lint install-linter help format swagger watch
.PHONY: help confirm lint test format build run docker swagger watch migrate/status migrate/new migrate/up migrate/down start: build
$(BUILD_DIR)/$(BINARY_NAME)
confirm:
@echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ]
lint:
which golangci-lint || (go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.0)
golangci-lint run --config=$(ROOT)/.golangci.yml $(ROOT)/...
test: test:
go test -v ./... go test -v ./...
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(BINARY_NAME) main.go
clean:
rm -rf $(BUILD_DIR)/
mod-tidy:
go mod tidy
lint:
golangci-lint run
install-linter:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
format: format:
@which gofumpt || (go install mvdan.cc/gofumpt@latest) @which gofumpt || (go install mvdan.cc/gofumpt@latest)
@gofumpt -l -w $(ROOT) @gofumpt -l -w .
@which gci || (go install github.com/daixiang0/gci@latest) @which gci || (go install github.com/daixiang0/gci@latest)
@gci write $(ROOT) --skip-generated --skip-vendor @gci write . --skip-generated --skip-vendor
@which golangci-lint || (go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.0)
@golangci-lint run --fix @golangci-lint run --fix
build:
go build -o niki main.go
run:
go run main.go --migrate
docker:
sudo docker compose up -d
swagger: swagger:
swag init swag init
# Live Reload # Live Reload
watch: watch:
@if command -v CompileDaemon > /dev/null; then \ @if command -v CompileDaemon > /dev/null; then \
CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./niki"; \ CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./$(BUILD_DIR)/$(BINARY_NAME)"; \
else \ else \
read -p "Go's 'CompileDaemon' is not installed on your machine. Do you want to install it? [Y/n] " choice; \ read -p "Go's 'CompileDaemon' is not installed. Do you want to install it? [Y/n] " choice; \
if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \
go install github.com/githubnemo/CompileDaemon@latest; \ go install github.com/githubnemo/CompileDaemon@latest; \
CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./niki"; \ CompileDaemon -exclude-dir=.git -exclude=".#*" -command="./$(BUILD_DIR)/$(BINARY_NAME)"; \
else \ else \
echo "You chose not to install CompileDaemon. Exiting..."; \ echo "You chose not to install CompileDaemon. Exiting..."; \
exit 1; \ exit 1; \
fi; \ fi; \
fi fi
# ====================================================================================
# Database Migration Commands (legacy niki-core)
# ====================================================================================
.PHONY: migrate/status migrate/new migrate/up migrate/down
migrate/status: migrate/status:
@sql-migrate status -env="production" -config=repository/mysql/dbconfig.yml @sql-migrate status -env="production" -config=repository/mysql/dbconfig.yml
@ -64,3 +74,67 @@ migrate/up: confirm
migrate/down: confirm migrate/down: confirm
@echo 'Tearing down last migration...' @echo 'Tearing down last migration...'
@sql-migrate down -env="production" -config=repository/mysql/dbconfig.yml -limit=1 @sql-migrate down -env="production" -config=repository/mysql/dbconfig.yml -limit=1
confirm:
@echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ]
# ====================================================================================
# Productapp Service Commands
# ====================================================================================
.PHONY: productapp-up productapp-up-logs productapp-rebuild productapp-watch
PRODUCTAPP_ENV = deploy/productapp/development/.env
PRODUCTAPP_COMPOSE = deploy/productapp/development/docker-compose.yml
productapp-up:
@echo "Building and starting productapp full stack..."
docker compose --env-file $(PRODUCTAPP_ENV) -f $(PRODUCTAPP_COMPOSE) up --build -d
@echo "productapp stack is up."
productapp-down:
@echo "Building and starting productapp full stack..."
docker compose --env-file $(PRODUCTAPP_ENV) -f $(PRODUCTAPP_COMPOSE) down
@echo "productapp stack is up."
productapp-up-logs:
@echo "Building and starting productapp full stack..."
docker compose --env-file $(PRODUCTAPP_ENV) -f $(PRODUCTAPP_COMPOSE) up --build
# ====================================================================================
# Docker Commands (legacy)
# ====================================================================================
.PHONY: docker
docker:
docker compose up -d
# ====================================================================================
# Help Target
# ====================================================================================
help:
@echo "Available targets:"
@echo ""
@echo "General Go Commands:"
@echo " start - Build and run niki-core locally"
@echo " test - Run tests"
@echo " build - Compile binary"
@echo " clean - Remove build artifacts"
@echo " mod-tidy - Clean up dependencies"
@echo " format - Format code (gofumpt + gci + lint fix)"
@echo " lint - Run linters"
@echo " install-linter - Install golangci-lint"
@echo " swagger - Generate swagger docs"
@echo " watch - Live reload with CompileDaemon"
@echo ""
@echo "Database Migration (niki-core):"
@echo " migrate/status - Show migration status"
@echo " migrate/new - Create new migration (name=<name>)"
@echo " migrate/up - Run migrations up"
@echo " migrate/down - Rollback last migration"
@echo ""
@echo "Productapp Service:"
@echo " productapp-up - Build and start full stack in background"
@echo " productapp-up-logs - Build and start full stack with logs"
@echo ""
@echo "Docker (legacy):"
@echo " docker - Start docker-compose services"

View File

@ -1,13 +1,16 @@
package command package command
import ( import (
"fmt" "context"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"syscall" "syscall"
"time"
"git.gocasts.ir/ebhomengo/niki/productapp"
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -25,27 +28,53 @@ var serveCmd = &cobra.Command{
func serve() { func serve() {
log.Println("Product Service Starting...") log.Println("Product Service Starting...")
// TODO: Initialize database connection cfg := productapp.Config{
// TODO: Initialize service dependencies HTTPServer: productapp.HTTPServerConfig{
// TODO: Setup HTTP server with routes Port: getEnvInt("HTTP_PORT", 8080),
},
Database: mysql.Config{
Username: getEnv("DB_USERNAME", "root"),
Password: getEnv("DB_PASSWORD", ""),
Port: getEnvInt("DB_PORT", 3306),
Host: getEnv("DB_HOST", "localhost"),
DBName: getEnv("DB_NAME", "niki_db"),
},
}
if p, err := strconv.Atoi(port); err == nil && p > 0 {
cfg.HTTPServer.Port = p
}
app := productapp.Setup(cfg)
// Setup graceful shutdown
go func() { go func() {
sigCh := make(chan os.Signal, 1) app.Start()
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("Shutting down Product Service gracefully...")
os.Exit(0)
}() }()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { quit := make(chan os.Signal, 1)
fmt.Fprintf(w, "Product Service OK!") signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
}) <-quit
log.Printf("Product Service listening on port %s", port) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := http.ListenAndServe(":"+port, nil); err != nil { defer cancel()
log.Fatalf("Failed to start server: %v", err)
if err := app.Stop(ctx); err != nil {
log.Fatalf("Server shutdown error: %v", err)
} }
log.Println("Product Service stopped.")
}
func getEnvInt(key string, defaultValue int) int {
val := os.Getenv(key)
if val == "" {
return defaultValue
}
n, err := strconv.Atoi(val)
if err != nil {
return defaultValue
}
return n
} }
func init() { func init() {

View File

@ -0,0 +1,19 @@
ARG GO_IMAGE_NAME
ARG GO_IMAGE_VERSION
FROM ${GO_IMAGE_NAME}:${GO_IMAGE_VERSION}
ENV GOPROXY=https://package-mirror.liara.ir/repository/go/
ENV GOSUMDB=off
WORKDIR /home/app
COPY go.mod go.sum ./
RUN go mod download
RUN go install github.com/air-verse/air@latest
RUN printf '#!/bin/sh\n./cmd/productapp/temp/main migrate --up\nexec ./cmd/productapp/temp/main serve\n' > /entrypoint.sh && chmod +x /entrypoint.sh
COPY . .
CMD ["air", "-c", "/home/app/.air/.air.productapp.toml"]

View File

@ -0,0 +1,20 @@
services:
productapp-mysql:
image: mirror2.chabokan.net/mysql:8.0
container_name: productapp-mysql
restart: always
ports:
- "3307:3306"
volumes:
- productapp-mysql-data:/var/lib/mysql
environment:
MYSQL_DATABASE: niki_db
MYSQL_ROOT_PASSWORD: secret
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
productapp-mysql-data:

View File

@ -0,0 +1,43 @@
services:
productapp-app:
build:
context: ../../..
dockerfile: deploy/productapp/development/Dockerfile
args:
GO_IMAGE_NAME: ${GO_IMAGE_NAME}
GO_IMAGE_VERSION: ${GO_IMAGE_VERSION}
container_name: productapp-app
ports:
- "8080:8080"
volumes:
- ../../..:/home/app
environment:
DB_HOST: productapp-mysql
DB_USERNAME: root
DB_PASSWORD: secret
DB_NAME: niki_db
MIGRATION_PATH: /home/app/productapp/repository/migrations
depends_on:
productapp-mysql:
condition: service_healthy
restart: unless-stopped
productapp-mysql:
image: mirror2.chabokan.net/mysql:8.0
container_name: productapp-mysql
restart: always
ports:
- "3307:3306"
volumes:
- productapp-mysql-data:/var/lib/mysql
environment:
MYSQL_DATABASE: niki_db
MYSQL_ROOT_PASSWORD: secret
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
productapp-mysql-data:

View File

@ -1,6 +1,23 @@
package errmsg package errmsg
type ErrorResponse struct {
Message string `json:"message"` // General error message
Errors map[string]interface{} `json:"errors,omitempty"` // Additional detail of error
InternalErrCode string `json:"internal_error_code,omitempty"` // Custom error code (optional)
}
func (e ErrorResponse) Error() string {
return e.Message
}
const ( const (
ErrValidationFailed = "input validation failed"
ErrUnexpectedError = "unexpected error occurred"
ErrInvalidRequestFormat = "invalid request format"
ErrGetUserInfo = "get user info failed"
ErrFailedDecodeBase64 = "decode data to base 64 failed"
ErrFailedUnmarshalJson = "unmarshal data to JSON failed"
ErrUnauthorized = "unauthorized"
ErrorMsgAdminNotAllowed = "admin is not allowed" ErrorMsgAdminNotAllowed = "admin is not allowed"
ErrorMsgNotFound = "record not found" ErrorMsgNotFound = "record not found"
ErrorMsgSomethingWentWrong = "something went wrong" ErrorMsgSomethingWentWrong = "something went wrong"

View File

@ -1,8 +1,69 @@
package productapp package productapp
import "net/http" import (
"context"
"fmt"
"git.gocasts.ir/ebhomengo/niki/productapp/service/product"
"log"
httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server"
producthttp "git.gocasts.ir/ebhomengo/niki/productapp/delivery/http"
productdb "git.gocasts.ir/ebhomengo/niki/productapp/repository/database"
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type HTTPServerConfig struct {
Port int
}
type Config struct {
HTTPServer HTTPServerConfig
Database mysql.Config
}
type Application struct { type Application struct {
Config Config Config Config
HTTPServer *http.Server HTTPServer *producthttp.Server
productSvc product.Service
DB *productdb.DB
Echo *echo.Echo
}
func Setup(cfg Config) *Application {
db := mysql.New(cfg.Database)
productDB := productdb.New(db)
e := echo.New()
e.Use(middleware.Recover())
hsrv := &httpserver.Server{
Router: e,
}
productSvc := product.New(productDB)
srv := producthttp.NewServer(hsrv, productSvc)
srv.RegisterRoutes()
return &Application{
Config: cfg,
productSvc: productSvc,
HTTPServer: srv,
DB: productDB,
Echo: e,
}
}
func (app *Application) Start() {
address := fmt.Sprintf(":%d", app.Config.HTTPServer.Port)
log.Printf("Product Service listening on %s", address)
if err := app.Echo.Start(address); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
func (app *Application) Stop(ctx context.Context) error {
return app.Echo.Shutdown(ctx)
} }

View File

@ -1,4 +1 @@
package productapp package productapp
type Config struct {
}

View File

@ -1,17 +1,38 @@
package http package http
import ( import (
"net/http" errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg"
"git.gocasts.ir/ebhomengo/niki/productapp/service/product"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"log/slog"
"net/http"
) )
type Handler struct{} type Handler struct {
productService product.Service
func NewHandler() *Handler { Logger *slog.Logger
return &Handler{}
} }
func (h Handler) HealthCheck(c echo.Context) error { func NewHandler(productService product.Service) *Handler {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) return &Handler{
productService: productService,
}
}
func (h Handler) getProductList(c echo.Context) error {
var req product.GetProductListRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, errmsg.ErrorResponse{
Message: errmsg.ErrInvalidRequestFormat,
})
}
response, err := h.productService.GetProducts(c.Request().Context(), req)
if err != nil {
// todo handle validation error
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, response)
} }

View File

@ -0,0 +1,11 @@
package http
import (
"net/http"
"github.com/labstack/echo/v4"
)
func (h Handler) HealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "healthy"})
}

View File

@ -1 +0,0 @@
package http

View File

@ -1,16 +1,19 @@
package http package http
import httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server" import (
httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server"
"git.gocasts.ir/ebhomengo/niki/productapp/service/product"
)
type Server struct { type Server struct {
HTTPServer *httpserver.Server HTTPServer *httpserver.Server
Handler *Handler Handler *Handler
} }
func NewServer(httpserver *httpserver.Server) *Server { func NewServer(httpserver *httpserver.Server, productSvc product.Service) *Server {
return &Server{ return &Server{
HTTPServer: httpserver, HTTPServer: httpserver,
Handler: NewHandler(), Handler: NewHandler(productSvc),
} }
} }
@ -20,4 +23,12 @@ func (s *Server) Serve() {
func (s *Server) Stop() {} func (s *Server) Stop() {}
func (s *Server) RegisterRoutes() {} func (s *Server) RegisterRoutes() {
r := s.HTTPServer.Router
r.GET("health-check", s.Handler.HealthCheck)
productGroup := r.Group("/v1/products")
productGroup.GET("", s.Handler.getProductList)
}

View File

@ -1,6 +1,11 @@
package database package database
import "git.gocasts.ir/ebhomengo/niki/repository/mysql" import (
"context"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/productapp/service/product"
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
)
type DB struct { type DB struct {
conn *mysql.DB conn *mysql.DB
@ -11,3 +16,42 @@ func New(conn *mysql.DB) *DB {
conn: conn, conn: conn,
} }
} }
func (db *DB) GetProducts(ctx context.Context, page, pageSize uint) ([]product.Product, error) {
const Op = "domain.repository.mysql.order.get-shipping"
query := "SELECT * FROM products ORDER BY id DESC limit ? offset ?"
rows, err := db.conn.Conn().Query(query, page, pageSize)
if rows.Err() != nil {
return []product.Product{}, richerror.New(Op).WithErr(err)
}
defer rows.Close()
var products []product.Product
for rows.Next() {
var s product.Product
err := rows.Scan(
&s.ID,
&s.Name,
&s.Slug,
&s.Description,
&s.Price,
&s.Stock,
&s.IsActive,
&s.Features,
&s.CreatedAt,
&s.DeletedAt,
)
if err != nil {
return nil, richerror.New(Op).WithErr(err)
}
products = append(products, s)
}
return products, nil
}

View File

@ -9,8 +9,7 @@ CREATE TABLE `products` (
`is_active` BOOLEAN DEFAULT TRUE, `is_active` BOOLEAN DEFAULT TRUE,
`features` JSON DEFAULT NULL, `features` JSON DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`deleted_at` TIMESTAMP DEFAULT NULL, `deleted_at` TIMESTAMP DEFAULT NULL
FOREIGN KEY (`category_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_persian_ci; ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_persian_ci;
-- +migrate Down -- +migrate Down

View File

@ -6,16 +6,16 @@ import (
) )
type Product struct { type Product struct {
ID uint ID uint `json:"id"`
Name string Name string `json:"name"`
Slug string Slug string `json:"slug"`
Description string Description string `json:"description"`
Price float64 Price float64 `json:"price"`
Stock int Stock int `json:"stock"`
IsActive bool IsActive bool `json:"is_active"`
Features string Features string `json:"features"`
CreatedAt time.Time CreatedAt time.Time `json:"created_at"`
DeletedAt sql.NullTime DeletedAt sql.NullTime `json:"-"`
} }
type ProductImage struct { type ProductImage struct {

View File

@ -0,0 +1,13 @@
package product
import "errors"
var (
ErrFailedGetProductList = errors.New("failed to get products list")
ErrFailedCreateProduct = errors.New("failed to create product")
ErrFailedFindProduct = errors.New("failed to find product")
ErrCantAccessToUpdateProduct = errors.New("you are not allowed to edit this product")
ErrFailedUpdateProduct = errors.New("failed to update product")
ErrFailedToPreparedStatement = errors.New("failed to prepared statement: %v")
ErrCantAccess = errors.New("you are not allowed to this action")
)

View File

@ -1 +1,12 @@
package product package product
import "git.gocasts.ir/ebhomengo/niki/param"
type GetProductListRequest struct {
param.PaginationRequest
}
type GetProductListResponse struct {
Data []Product `json:"data"`
param.PaginationResponse
}

View File

@ -1 +1,38 @@
package product package product
import (
"context"
"fmt"
errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg"
)
type Repository interface {
GetProducts(ctx context.Context, page, pageSize uint) ([]Product, error)
}
type Service struct {
repository Repository
}
func New(repo Repository) Service {
return Service{
repository: repo,
}
}
func (s Service) GetProducts(ctx context.Context, req GetProductListRequest) (GetProductListResponse, error) {
page := req.GetPageNumber()
pageSize := req.PageSize
products, err := s.repository.GetProducts(ctx, page, pageSize)
if err != nil {
fmt.Println(err)
return GetProductListResponse{}, errmsg.ErrorResponse{
Message: ErrFailedGetProductList.Error(),
Errors: map[string]interface{}{"repository_error": err.Error()},
}
}
return GetProductListResponse{Data: products}, nil
}