diff --git a/README.md b/README.md index dee7db5..e388032 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ GET {{baseUrl}}/ Content-Type: {{contentType}} ### Get Stocks by Year -GET {{baseUrl}}/api/v1/stocks/year/2025 +GET {{baseUrl}}/api/v1/stocks/year/2023 Content-Type: {{contentType}} ### Search Stocks with Parameters @@ -293,8 +293,13 @@ Data source: [Almaqased Cleansing Calculator](https://almaqased.net/cleansing-ca │   ├── middleware │   │   ├── middleware.go │   │   └── year_validator.go -│   └── routes -│   └── routes.go +│   ├── models +│   │   ├── error.go +│   │   └── stock.go +│   ├── routes +│   │   └── routes.go +│   └── utils +│   └── safe.go ├── LICENSE ├── Makefile └── README.md diff --git a/api.http b/api.http index b3c7998..53330f2 100644 --- a/api.http +++ b/api.http @@ -1,7 +1,13 @@ -### Environment Variables +// ############################################################################### +// Environment Variables +// ############################################################################### @baseUrl = http://localhost:3000 @contentType = application/json +// ############################################################################### +// API Endpoints +// ############################################################################### + ### Health Check GET {{baseUrl}}/api/health Content-Type: {{contentType}} @@ -15,7 +21,7 @@ GET {{baseUrl}}/ Content-Type: {{contentType}} ### Get Stocks by Year -GET {{baseUrl}}/api/v1/stocks/year/2018 +GET {{baseUrl}}/api/v1/stocks/year/2023 Content-Type: {{contentType}} ### Search Stocks with Parameters @@ -33,7 +39,9 @@ Content-Type: {{contentType}} "stock_code": "1111" } -### Search Examples +// ############################################################################### +// Search Examples +// ############################################################################### ### Search by Name GET {{baseUrl}}/api/v1/stocks/year/2023/search?name=%D8%A3%D8%B1%D8%A7%D9%85%D9%83%D9%88 diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index 349212f..bd6a32d 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -1,27 +1,21 @@ package handlers import ( + "github.com/anqorithm/naqa-api/internal/models" "github.com/gofiber/fiber/v2" ) -type ErrorResponse struct { - Status string `json:"status"` - Code string `json:"code"` - Message string `json:"message"` - Details interface{} `json:"details,omitempty"` -} +// ############################################################################### +// Error Handling Functions +// ############################################################################### -func NewErrorResponse(code string, message string, details interface{}) *ErrorResponse { - return &ErrorResponse{ +func SendError(c *fiber.Ctx, status int, code string, message string, details interface{}) error { + return c.Status(status).JSON(&models.ErrorResponse{ Status: "error", Code: code, Message: message, Details: details, - } -} - -func SendError(c *fiber.Ctx, status int, code string, message string, details interface{}) error { - return c.Status(status).JSON(NewErrorResponse(code, message, details)) + }) } // Common error codes diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 3607852..17af9bc 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -19,6 +19,10 @@ func NewHandler(db *mongo.Database) *Handler { } } +// ############################################################################### +// Handler Functions +// ############################################################################### + // RootHandler handles the "/" endpoint func (h *Handler) RootHandler(c *fiber.Ctx) error { return c.JSON(fiber.Map{ @@ -33,18 +37,18 @@ func (h *Handler) RootHandler(c *fiber.Ctx) error { // ApiV1Handler handles the "/api/v1" endpoint func (h *Handler) ApiV1Handler(c *fiber.Ctx) error { - return c.JSON(fiber.Map{ - "status": "active", - "message": "Welcome to NAQA API v1", - "version": "1.0.0", - "env": os.Getenv("APP_ENV"), - "server_time": time.Now().Format(time.RFC3339), - "request_id": c.Get("X-Request-ID", uuid.New().String()), - "endpoints": []string{ - "/api/v1/stocks", - "/api/v1/health", - }, - }) + return c.JSON(fiber.Map{ + "status": "active", + "message": "Welcome to NAQA API v1", + "version": "1.0.0", + "env": os.Getenv("APP_ENV"), + "server_time": time.Now().Format(time.RFC3339), + "request_id": c.Get("X-Request-ID", uuid.New().String()), + "endpoints": []string{ + "/api/v1/stocks", + "/api/v1/health", + }, + }) } // HealthCheckHandler handles the health check endpoint diff --git a/internal/handlers/stocks.go b/internal/handlers/stocks.go index 184f554..c08adec 100644 --- a/internal/handlers/stocks.go +++ b/internal/handlers/stocks.go @@ -1,180 +1,150 @@ package handlers import ( - "fmt" "strconv" "time" + "github.com/anqorithm/naqa-api/internal/models" + "github.com/anqorithm/naqa-api/internal/utils" "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/bson" ) -type Stock struct { - Name string `json:"name" bson:"name"` - Code string `json:"code" bson:"code"` - Sector string `json:"sector" bson:"sector"` - ShariaOpinion string `json:"sharia_opinion" bson:"sharia_opinion"` - Purification string `json:"purification" bson:"purification"` -} - -type StockResponse struct { - Stocks []Stock `json:"stocks"` -} - -type PurificationRequest struct { - StartDate string `json:"start_date"` - EndDate string `json:"end_date"` - NumberOfStocks int `json:"number_of_stocks"` - StockCode string `json:"stock_code"` -} - -type PurificationResponse struct { - PurificationAmount float64 `json:"purification_amount"` - DaysHeld int `json:"days_held"` - PurificationRate float64 `json:"purification_rate"` -} - -func safeString(v interface{}) string { - if str, ok := v.(string); ok { - return str - } - if num, ok := v.(float64); ok { - return fmt.Sprintf("%.2f", num) - } - return "" -} +// ############################################################################### +// Stock Handler Functions +// ############################################################################### func (h *Handler) GetStocksByYearHandler(c *fiber.Ctx) error { - collection := h.db.Collection(c.Params("year")) - - var stocks []bson.M - cursor, err := collection.Find(c.Context(), bson.M{}) - if err != nil { - return SendError(c, fiber.StatusInternalServerError, ErrCodeDatabaseError, - "Failed to fetch stocks", nil) - } - defer cursor.Close(c.Context()) - - if err := cursor.All(c.Context(), &stocks); err != nil { - return SendError(c, fiber.StatusInternalServerError, ErrCodeDatabaseError, - "Failed to parse stocks", nil) - } - - result := make([]Stock, 0, len(stocks)) - for _, doc := range stocks { - stock := Stock{ - Name: safeString(doc["name"]), - Code: safeString(doc["code"]), - Sector: safeString(doc["sector"]), - ShariaOpinion: safeString(doc["sharia_opinion"]), - Purification: safeString(doc["purification"]), - } - result = append(result, stock) - } - - return c.JSON(StockResponse{Stocks: result}) + collection := h.db.Collection(c.Params("year")) + + var stocks []bson.M + cursor, err := collection.Find(c.Context(), bson.M{}) + if err != nil { + return SendError(c, fiber.StatusInternalServerError, models.ErrCodeDatabaseError, + "Failed to fetch stocks", nil) + } + defer cursor.Close(c.Context()) + + if err := cursor.All(c.Context(), &stocks); err != nil { + return SendError(c, fiber.StatusInternalServerError, models.ErrCodeDatabaseError, + "Failed to parse stocks", nil) + } + + result := make([]models.Stock, 0, len(stocks)) + for _, doc := range stocks { + stock := models.Stock{ + Name: utils.SafeString(doc["name"]), + Code: utils.SafeString(doc["code"]), + Sector: utils.SafeString(doc["sector"]), + ShariaOpinion: utils.SafeString(doc["sharia_opinion"]), + Purification: utils.SafeString(doc["purification"]), + } + result = append(result, stock) + } + + return c.JSON(models.StockResponse{Stocks: result}) } func (h *Handler) SearchStocksHandler(c *fiber.Ctx) error { - filter := bson.M{} - - if name := c.Query("name"); name != "" { - filter["name"] = bson.M{"$regex": name, "$options": "i"} - } - if code := c.Query("code"); code != "" { - filter["code"] = code - } - if sector := c.Query("sector"); sector != "" { - filter["sector"] = sector - } - if shariaOpinion := c.Query("sharia_opinion"); shariaOpinion != "" { - filter["sharia_opinion"] = shariaOpinion - } - if purification := c.Query("purification"); purification != "" { - filter["purification"] = purification - } - - collection := h.db.Collection(c.Params("year")) - var stocks []bson.M - cursor, err := collection.Find(c.Context(), filter) - if err != nil { - return c.Status(500).JSON(fiber.Map{"error": "Failed to search stocks"}) - } - defer cursor.Close(c.Context()) - - if err := cursor.All(c.Context(), &stocks); err != nil { - return c.Status(500).JSON(fiber.Map{"error": "Failed to parse stocks"}) - } - - result := make([]Stock, 0, len(stocks)) - for _, doc := range stocks { - stock := Stock{ - Name: safeString(doc["name"]), - Code: safeString(doc["code"]), - Sector: safeString(doc["sector"]), - ShariaOpinion: safeString(doc["sharia_opinion"]), - Purification: safeString(doc["purification"]), - } - result = append(result, stock) - } - - return c.JSON(StockResponse{Stocks: result}) + filter := bson.M{} + + if name := c.Query("name"); name != "" { + filter["name"] = bson.M{"$regex": name, "$options": "i"} + } + if code := c.Query("code"); code != "" { + filter["code"] = code + } + if sector := c.Query("sector"); sector != "" { + filter["sector"] = sector + } + if shariaOpinion := c.Query("sharia_opinion"); shariaOpinion != "" { + filter["sharia_opinion"] = shariaOpinion + } + if purification := c.Query("purification"); purification != "" { + filter["purification"] = purification + } + + collection := h.db.Collection(c.Params("year")) + var stocks []bson.M + cursor, err := collection.Find(c.Context(), filter) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to search stocks"}) + } + defer cursor.Close(c.Context()) + + if err := cursor.All(c.Context(), &stocks); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to parse stocks"}) + } + + result := make([]models.Stock, 0, len(stocks)) + for _, doc := range stocks { + stock := models.Stock{ + Name: utils.SafeString(doc["name"]), + Code: utils.SafeString(doc["code"]), + Sector: utils.SafeString(doc["sector"]), + ShariaOpinion: utils.SafeString(doc["sharia_opinion"]), + Purification: utils.SafeString(doc["purification"]), + } + result = append(result, stock) + } + + return c.JSON(models.StockResponse{Stocks: result}) } func (h *Handler) CalculatePurificationHandler(c *fiber.Ctx) error { - var req PurificationRequest - if err := c.BodyParser(&req); err != nil { - return SendError(c, fiber.StatusBadRequest, ErrCodeInvalidRequest, - "Invalid request body", nil) - } - - startDate, err := time.Parse("2006-01-02", req.StartDate) - if err != nil { - return SendError(c, fiber.StatusBadRequest, ErrCodeInvalidDateFormat, - "Invalid start date format", map[string]string{ - "expected_format": "YYYY-MM-DD", - "provided_value": req.StartDate, - }) - } - - endDate, err := time.Parse("2006-01-02", req.EndDate) - if err != nil { - return SendError(c, fiber.StatusBadRequest, ErrCodeInvalidDateFormat, - "Invalid end date format", map[string]string{ - "expected_format": "YYYY-MM-DD", - "provided_value": req.EndDate, - }) - } - - daysHeld := int(endDate.Sub(startDate).Hours() / 24) - if daysHeld < 0 { - return SendError(c, fiber.StatusBadRequest, ErrCodeValidationFailed, - "End date must be after start date", map[string]string{ - "start_date": req.StartDate, - "end_date": req.EndDate, - }) - } - - collection := h.db.Collection(c.Params("year")) - var stock bson.M - err = collection.FindOne(c.Context(), bson.M{"code": req.StockCode}).Decode(&stock) - if err != nil { - return c.Status(404).JSON(fiber.Map{"error": "Stock not found"}) - } - - purificationStr := safeString(stock["purification"]) - purificationRate, err := strconv.ParseFloat(purificationStr, 64) - if err != nil { - return c.Status(500).JSON(fiber.Map{"error": "Invalid purification rate in database"}) - } - - purificationAmount := float64(req.NumberOfStocks) * purificationRate * float64(daysHeld) / 365.0 - - response := PurificationResponse{ - PurificationAmount: purificationAmount, - DaysHeld: daysHeld, - PurificationRate: purificationRate, - } - - return c.JSON(response) -} \ No newline at end of file + var req models.PurificationRequest + if err := c.BodyParser(&req); err != nil { + return SendError(c, fiber.StatusBadRequest, models.ErrCodeInvalidRequest, + "Invalid request body", nil) + } + + startDate, err := time.Parse("2006-01-02", req.StartDate) + if err != nil { + return SendError(c, fiber.StatusBadRequest, models.ErrCodeInvalidDateFormat, + "Invalid start date format", map[string]string{ + "expected_format": "YYYY-MM-DD", + "provided_value": req.StartDate, + }) + } + + endDate, err := time.Parse("2006-01-02", req.EndDate) + if err != nil { + return SendError(c, fiber.StatusBadRequest, models.ErrCodeInvalidDateFormat, + "Invalid end date format", map[string]string{ + "expected_format": "YYYY-MM-DD", + "provided_value": req.EndDate, + }) + } + + daysHeld := int(endDate.Sub(startDate).Hours() / 24) + if daysHeld < 0 { + return SendError(c, fiber.StatusBadRequest, models.ErrCodeValidationFailed, + "End date must be after start date", map[string]string{ + "start_date": req.StartDate, + "end_date": req.EndDate, + }) + } + + collection := h.db.Collection(c.Params("year")) + var stock bson.M + err = collection.FindOne(c.Context(), bson.M{"code": req.StockCode}).Decode(&stock) + if err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Stock not found"}) + } + + purificationStr := utils.SafeString(stock["purification"]) + purificationRate, err := strconv.ParseFloat(purificationStr, 64) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Invalid purification rate in database"}) + } + + purificationAmount := float64(req.NumberOfStocks) * purificationRate * float64(daysHeld) / 365.0 + + response := models.PurificationResponse{ + PurificationAmount: purificationAmount, + DaysHeld: daysHeld, + PurificationRate: purificationRate, + } + + return c.JSON(response) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index d338fb1..373e8cf 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -12,6 +12,10 @@ import ( "github.com/gofiber/fiber/v2/middleware/monitor" ) +// ############################################################################### +// Middleware Functions +// ############################################################################### + // Logger returns a logger middleware func Logger() fiber.Handler { return logger.New(logger.Config{ diff --git a/internal/middleware/year_validator.go b/internal/middleware/year_validator.go index 2bdf4af..9d4d164 100644 --- a/internal/middleware/year_validator.go +++ b/internal/middleware/year_validator.go @@ -4,44 +4,49 @@ import ( "fmt" "github.com/anqorithm/naqa-api/internal/handlers" + "github.com/anqorithm/naqa-api/internal/models" "github.com/gofiber/fiber/v2" ) +// ############################################################################### +// Year Validation Middleware +// ############################################################################### + var availableYears = []string{"2018", "2019", "2020", "2021", "2022", "2023"} func ValidateYear() fiber.Handler { - return func(c *fiber.Ctx) error { - year := c.Params("year") - if year == "" { - return handlers.SendError(c, fiber.StatusBadRequest, - handlers.ErrCodeValidationFailed, - "Year parameter is required", - fiber.Map{ - "available_years": availableYears, - "example": fmt.Sprintf("/api/v1/stocks/year/%s", availableYears[len(availableYears)-1]), - }) - } - - valid := false - for _, y := range availableYears { - if y == year { - valid = true - break - } - } - - if !valid { - return handlers.SendError(c, fiber.StatusBadRequest, - handlers.ErrCodeValidationFailed, - fmt.Sprintf("The year '%s' is not supported", year), - fiber.Map{ - "provided_year": year, - "available_years": availableYears, - "latest_year": availableYears[len(availableYears)-1], - "suggestion": fmt.Sprintf("Try using the latest available year: %s", availableYears[len(availableYears)-1]), - }) - } - - return c.Next() - } + return func(c *fiber.Ctx) error { + year := c.Params("year") + if year == "" { + return handlers.SendError(c, fiber.StatusBadRequest, + models.ErrCodeValidationFailed, + "Year parameter is required", + fiber.Map{ + "available_years": availableYears, + "example": fmt.Sprintf("/api/v1/stocks/year/%s", availableYears[len(availableYears)-1]), + }) + } + + valid := false + for _, y := range availableYears { + if y == year { + valid = true + break + } + } + + if !valid { + return handlers.SendError(c, fiber.StatusBadRequest, + models.ErrCodeValidationFailed, + fmt.Sprintf("The year '%s' is not supported", year), + fiber.Map{ + "provided_year": year, + "available_years": availableYears, + "latest_year": availableYears[len(availableYears)-1], + "suggestion": fmt.Sprintf("Try using the latest available year: %s", availableYears[len(availableYears)-1]), + }) + } + + return c.Next() + } } diff --git a/internal/models/error.go b/internal/models/error.go new file mode 100644 index 0000000..c292a56 --- /dev/null +++ b/internal/models/error.go @@ -0,0 +1,18 @@ +package models + +type ErrorResponse struct { + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` +} + +// Common error codes +const ( + ErrCodeInvalidRequest = "INVALID_REQUEST" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeInternalError = "INTERNAL_ERROR" + ErrCodeValidationFailed = "VALIDATION_FAILED" + ErrCodeDatabaseError = "DATABASE_ERROR" + ErrCodeInvalidDateFormat = "INVALID_DATE_FORMAT" +) \ No newline at end of file diff --git a/internal/models/stock.go b/internal/models/stock.go new file mode 100644 index 0000000..bf17af1 --- /dev/null +++ b/internal/models/stock.go @@ -0,0 +1,26 @@ +package models + +type Stock struct { + Name string `json:"name" bson:"name"` + Code string `json:"code" bson:"code"` + Sector string `json:"sector" bson:"sector"` + ShariaOpinion string `json:"sharia_opinion" bson:"sharia_opinion"` + Purification string `json:"purification" bson:"purification"` +} + +type StockResponse struct { + Stocks []Stock `json:"stocks"` +} + +type PurificationRequest struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + NumberOfStocks int `json:"number_of_stocks"` + StockCode string `json:"stock_code"` +} + +type PurificationResponse struct { + PurificationAmount float64 `json:"purification_amount"` + DaysHeld int `json:"days_held"` + PurificationRate float64 `json:"purification_rate"` +} \ No newline at end of file diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 0554988..fd2d522 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -6,7 +6,7 @@ import ( "github.com/gofiber/fiber/v2" "go.mongodb.org/mongo-driver/mongo" ) - + type Router struct { app *fiber.App h *handlers.Handler @@ -19,6 +19,9 @@ func NewRouter(app *fiber.App, db *mongo.Database) *Router { } } +// ############################################################################### +// Router Setup and Server Startup +// ############################################################################### func (r *Router) SetupRoutes() { // Global middleware r.app.Use(middleware.Logger()) @@ -26,11 +29,11 @@ func (r *Router) SetupRoutes() { // Root route r.app.Get("/", r.h.RootHandler) - // API v1 routes + // API Version 1 Routes v1 := r.app.Group("/api/v1") r.setupV1Routes(v1) - // API routes + // General API Routes api := r.app.Group("/api") // Health check route @@ -41,14 +44,20 @@ func (r *Router) SetupRoutes() { }) } +// ############################################################################### +// API Version 1 Route Setup +// ############################################################################### func (r *Router) setupV1Routes(v1 fiber.Router) { r.setupStockRoutes(v1) } +// ############################################################################### +// Stock Routes Setup +// ############################################################################### func (r *Router) setupStockRoutes(v1 fiber.Router) { stocks := v1.Group("/stocks") - // Group routes under year + // Year-Based Stock Routes yearGroup := stocks.Group("/year/:year", middleware.ValidateYear()) // Get all stocks for a specific year diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..3a683d8 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,17 @@ +package utils + +import "fmt" + +// ############################################################################### +// Utility Functions +// ############################################################################### + +func SafeString(v interface{}) string { + if str, ok := v.(string); ok { + return str + } + if num, ok := v.(float64); ok { + return fmt.Sprintf("%.2f", num) + } + return "" +} \ No newline at end of file