├── internal ├── models │ ├── success.go │ ├── syrup │ │ ├── syrup_success.go │ │ ├── syrup_version.go │ │ ├── syrup_error.go │ │ ├── syrup_merchant.go │ │ └── syrup_coupon.go │ ├── tags.go │ ├── error.go │ ├── regions.go │ ├── healthcheck.go │ ├── categorie.go │ ├── vote_models.go │ ├── merchant.go │ ├── timestamparray.go │ └── coupon.go ├── handlers │ ├── get_health.go │ ├── syrup │ │ ├── get_version.go │ │ ├── post_invalid.go │ │ ├── get_merchants.go │ │ ├── post_valid.go │ │ └── get_coupons.go │ └── coupons │ │ ├── get_tags.go │ │ ├── get_by_id.go │ │ ├── get_regions.go │ │ ├── get_merchants.go │ │ ├── get_categories.go │ │ ├── post_vote.go │ │ ├── post_coupon.go │ │ └── get_coupons.go ├── database │ ├── postgres.go │ └── redis.go ├── config │ └── config.go ├── jobs │ └── score_updates.go ├── routes │ └── routes.go ├── middleware │ └── ratelimiter.go └── repositories │ └── coupon_repo.go ├── .env.example ├── .gitignore ├── docker-compose.yml ├── self_host.md ├── go.mod ├── cmd └── api │ └── main.go ├── README.md ├── go.sum ├── docs ├── swagger.yaml └── swagger.json └── LICENSE /internal/models/success.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Success struct { 4 | Message string `json:"message" example:"Success"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/models/syrup/syrup_success.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | type Success struct { 4 | Success string `json:"success" example:"true"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/models/tags.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type TagResponse struct { 4 | Tags []string `json:"tags"` 5 | Total int `json:"total"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ErrorResponse struct { 4 | Message string `json:"message" example:"Internal server error"` 5 | } 6 | -------------------------------------------------------------------------------- /internal/models/regions.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type RegionResponse struct { 4 | Regions []string `json:"regions"` 5 | Total int `json:"total"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/healthcheck.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type HealthCheckResponse struct { 4 | Status string `json:"status" example:"ok"` 5 | Version string `json:"version" example:"1.0"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/syrup/syrup_version.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | type VersionInfo struct { 4 | Version string `json:"version" example:"1.0.0"` 5 | Provider string `json:"provider" example:"DiscountDB"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/categorie.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CategoriesResponse struct { 4 | Total int `json:"total" example:"2"` 5 | Categories []string `json:"data" example:[\"Electronics\",\"Clothing\"]` 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/syrup/syrup_error.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | type ErrorResponse struct { 4 | Error string `json:"error" example:"Internal Server Error"` 5 | Message string `json:"message" example:"Something went wrong"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/models/vote_models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type Vote struct { 6 | ID int64 7 | Timestamp time.Time 8 | } 9 | 10 | type VoteBody struct { 11 | ID int64 `json:"id"` 12 | Dir string `json:"dir"` 13 | } 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DB_USERNAME = root 2 | DB_PASSWORD = root 3 | DB_HOST = localhost 4 | DB_PORT = 25060 5 | DB_DATABASE = yourdbname 6 | DB_SSL_MODE = disable 7 | 8 | REDIS_USERNAME = default 9 | REDIS_PASSWORD = root 10 | REDIS_HOST = localhost 11 | REDIS_PORT = 25061 12 | REDIS_USE_TLS = false -------------------------------------------------------------------------------- /internal/models/syrup/syrup_merchant.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | type Merchant struct { 4 | MerchantName string `json:"merchant_name"` 5 | Domains []string `json:"domains"` 6 | } 7 | 8 | type MerchantList struct { 9 | Merchants []Merchant `json:"merchants"` 10 | Total int `json:"total"` 11 | } 12 | -------------------------------------------------------------------------------- /internal/models/merchant.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Merchant struct { 4 | Name string `json:"merchant_name" example:"merchant1"` 5 | Domains []string `json:"merchant_url" example:["https://merchant1.com"]` 6 | } 7 | 8 | type MerchantResponse struct { 9 | Total int `json:"total" example:"2"` 10 | Data []Merchant `json:"data"` 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Go workspace file 15 | go.work 16 | go.work.sum 17 | 18 | # env file 19 | .env 20 | 21 | # Idea 22 | .idea 23 | .vscode -------------------------------------------------------------------------------- /internal/handlers/get_health.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "github.com/gofiber/fiber/v2" 4 | 5 | // HealthCheck godoc 6 | // @Summary Health check endpoint 7 | // @Description Get API health status 8 | // @Tags health 9 | // @Accept json 10 | // @Produce json 11 | // @Success 200 {object} models.HealthCheckResponse 12 | // @Failure 500 {object} models.ErrorResponse 13 | // @Router /health [get] 14 | func HealthCheck(c *fiber.Ctx) error { 15 | return c.JSON(fiber.Map{ 16 | "status": "ok", 17 | "version": "1.0", 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /internal/models/syrup/syrup_coupon.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | type Coupon struct { 4 | ID string `json:"id" example:"123"` 5 | Title string `json:"title" example:"Discount"` 6 | Description string `json:"description" example:"Get 10% off"` 7 | Code string `json:"code" example:"DISCOUNT10"` 8 | Score float64 `json:"score" example:"5"` 9 | } 10 | 11 | type CouponList struct { 12 | Coupons []Coupon `json:"coupons"` 13 | Total int `json:"total"` 14 | MerchantName string `json:"merchant_name" example:"Amazon"` 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:16 4 | container_name: postgres 5 | environment: 6 | POSTGRES_USER: root 7 | POSTGRES_PASSWORD: root 8 | POSTGRES_DB: discountdb 9 | ports: 10 | - "25060:5432" 11 | volumes: 12 | - postgres_data:/var/lib/postgresql/data 13 | networks: 14 | - app-network 15 | restart: unless-stopped 16 | 17 | redis: 18 | image: redis:7 19 | container_name: redis 20 | command: redis-server --requirepass root 21 | ports: 22 | - "25061:6379" 23 | volumes: 24 | - redis_data:/data 25 | networks: 26 | - app-network 27 | restart: unless-stopped 28 | 29 | volumes: 30 | postgres_data: 31 | redis_data: 32 | 33 | networks: 34 | app-network: 35 | driver: bridge -------------------------------------------------------------------------------- /internal/database/postgres.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "discountdb-api/internal/config" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | func NewPostgresDB(cfg *config.Config) (*sql.DB, error) { 11 | connStr := fmt.Sprintf( 12 | "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", 13 | cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBSSLMode, 14 | ) 15 | 16 | db, err := sql.Open("postgres", connStr) 17 | if err != nil { 18 | return nil, fmt.Errorf("error opening database connection: %w", err) 19 | } 20 | 21 | // Connection pool settings 22 | db.SetMaxOpenConns(25) 23 | db.SetMaxIdleConns(25) 24 | db.SetConnMaxLifetime(5 * time.Minute) 25 | 26 | // Test the connection 27 | if err := db.Ping(); err != nil { 28 | return nil, fmt.Errorf("error connecting to the database: %w", err) 29 | } 30 | 31 | return db, nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/joho/godotenv" 5 | "os" 6 | ) 7 | 8 | type Config struct { 9 | DBHost string 10 | DBPort string 11 | DBUser string 12 | DBPassword string 13 | DBName string 14 | DBSSLMode string 15 | 16 | REDISHost string 17 | REDISPort string 18 | REDISUser string 19 | REDISPassword string 20 | REDISUseTLS bool 21 | } 22 | 23 | func LoadConfig() (*Config, error) { 24 | _ = godotenv.Load() 25 | 26 | config := &Config{ 27 | DBHost: os.Getenv("DB_HOST"), 28 | DBPort: os.Getenv("DB_PORT"), 29 | DBUser: os.Getenv("DB_USERNAME"), 30 | DBPassword: os.Getenv("DB_PASSWORD"), 31 | DBName: os.Getenv("DB_DATABASE"), 32 | DBSSLMode: os.Getenv("DB_SSL_MODE"), 33 | 34 | REDISHost: os.Getenv("REDIS_HOST"), 35 | REDISPort: os.Getenv("REDIS_PORT"), 36 | REDISUser: os.Getenv("REDIS_USERNAME"), 37 | REDISPassword: os.Getenv("REDIS_PASSWORD"), 38 | REDISUseTLS: os.Getenv("REDIS_USE_TLS") == "true", 39 | } 40 | 41 | return config, nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/models/timestamparray.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql/driver" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type TimestampArray []time.Time 10 | 11 | func (a *TimestampArray) Scan(src interface{}) error { 12 | switch v := src.(type) { 13 | case []byte: 14 | return a.scanString(string(v)) 15 | case string: 16 | return a.scanString(v) 17 | case nil: 18 | *a = nil 19 | return nil 20 | default: 21 | return driver.ErrSkip 22 | } 23 | } 24 | 25 | func (a *TimestampArray) scanString(str string) error { 26 | if str == "{}" { 27 | *a = TimestampArray{} 28 | return nil 29 | } 30 | 31 | str = strings.Trim(str, "{}") 32 | parts := strings.Split(str, ",") 33 | 34 | times := make([]time.Time, len(parts)) 35 | for i, part := range parts { 36 | part = strings.Trim(strings.TrimSpace(part), "\"") 37 | t, err := time.Parse("2006-01-02 15:04:05.999999", part) 38 | if err != nil { 39 | return err 40 | } 41 | times[i] = t 42 | } 43 | 44 | *a = times 45 | return nil 46 | } 47 | 48 | func (a TimestampArray) Value() (driver.Value, error) { 49 | if a == nil { 50 | return nil, nil 51 | } 52 | 53 | strs := make([]string, len(a)) 54 | for i, t := range a { 55 | strs[i] = "\"" + t.Format("2006-01-02 15:04:05.999999") + "\"" 56 | } 57 | 58 | return "{" + strings.Join(strs, ",") + "}", nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/handlers/syrup/get_version.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | import ( 4 | "discountdb-api/internal/models/syrup" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | // GetVersionInfo godoc 9 | // @Summary Get API Version 10 | // @Description Returns information about the API implementation 11 | // @Tags syrup 12 | // @Produce json 13 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication" 14 | // @Success 200 {object} syrup.VersionInfo "Successful response" 15 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window" 16 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window" 17 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)" 18 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request" 19 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized" 20 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests" 21 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)" 22 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error" 23 | // @Router /syrup/version [get] 24 | func GetVersionInfo(ctx *fiber.Ctx) error { 25 | return ctx.JSON(syrup.VersionInfo{ 26 | Version: "1.0.0", 27 | Provider: "DiscountDB", 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /internal/database/redis.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "discountdb-api/internal/config" 7 | "fmt" 8 | "github.com/redis/go-redis/v9" 9 | "time" 10 | ) 11 | 12 | func NewRedisClient(cfg *config.Config) (*redis.Client, error) { 13 | var tlsConfig *tls.Config 14 | if cfg.REDISUseTLS { 15 | tlsConfig = &tls.Config{ 16 | MinVersion: tls.VersionTLS12, 17 | } 18 | } 19 | 20 | rdb := redis.NewClient(&redis.Options{ 21 | Addr: fmt.Sprintf("%s:%s", cfg.REDISHost, cfg.REDISPort), 22 | Username: cfg.REDISUser, 23 | Password: cfg.REDISPassword, 24 | DB: 0, 25 | 26 | // Connection Pool 27 | PoolSize: 30, 28 | MinIdleConns: 10, 29 | ConnMaxLifetime: 30 * time.Minute, 30 | PoolTimeout: 4 * time.Second, 31 | 32 | // Timeouts 33 | DialTimeout: 5 * time.Second, 34 | ReadTimeout: 5 * time.Second, 35 | WriteTimeout: 5 * time.Second, 36 | 37 | // Retry Strategy 38 | MaxRetries: 3, 39 | MinRetryBackoff: 8 * time.Millisecond, 40 | MaxRetryBackoff: 512 * time.Millisecond, 41 | 42 | // TLS Config 43 | TLSConfig: tlsConfig, 44 | }) 45 | 46 | // Test the connection 47 | ctx := context.Background() 48 | if err := rdb.Ping(ctx).Err(); err != nil { 49 | return nil, fmt.Errorf("error connecting to Redis: %w", err) 50 | } 51 | 52 | return rdb, nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/handlers/syrup/post_invalid.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/redis/go-redis/v9" 6 | ) 7 | 8 | // PostCouponInvalid godoc 9 | // @Summary Report Invalid Coupon 10 | // @Description Report that a coupon code failed to work 11 | // @Tags syrup 12 | // @Produce json 13 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication" 14 | // @Param id path string true "The ID of the coupon" 15 | // @Success 200 {object} syrup.Success "Successful response" 16 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window" 17 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window" 18 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)" 19 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request" 20 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized" 21 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests" 22 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)" 23 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error" 24 | // @Router /syrup/coupons/invalid/{id} [post] 25 | func PostCouponInvalid(ctx *fiber.Ctx, rdb *redis.Client) error { 26 | return PostCouponVote(ctx, rdb, "down") 27 | } 28 | -------------------------------------------------------------------------------- /internal/handlers/syrup/get_merchants.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | import ( 4 | "discountdb-api/internal/handlers/coupons" 5 | "discountdb-api/internal/models/syrup" 6 | "discountdb-api/internal/repositories" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | ) 10 | 11 | // GetMerchants godoc 12 | // @Summary List all Merchants 13 | // @Description Returns a list of all merchants and their domains 14 | // @Tags syrup 15 | // @Produce json 16 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication" 17 | // @Success 200 {object} syrup.MerchantList "Successful response" 18 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window" 19 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window" 20 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)" 21 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request" 22 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized" 23 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests" 24 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)" 25 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error" 26 | // @Router /syrup/merchants [get] 27 | func GetMerchants(ctx *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 28 | merchants, err := coupons.GetMerchantsResponse(ctx, couponRepo, rdb) 29 | 30 | if err != nil { 31 | return err 32 | } 33 | 34 | response := syrup.MerchantList{ 35 | Total: merchants.Total, 36 | } 37 | 38 | for _, merchant := range merchants.Data { 39 | response.Merchants = append(response.Merchants, syrup.Merchant{ 40 | MerchantName: merchant.Name, 41 | Domains: merchant.Domains, 42 | }) 43 | } 44 | 45 | return ctx.JSON(response) 46 | } 47 | -------------------------------------------------------------------------------- /self_host.md: -------------------------------------------------------------------------------- 1 | # Instructions for self-hosting 2 | 3 | This guide will help you set up and run the [DiscountDB API](https://github.com/ImGajeed76/discountdb-api) with 4 | containerized PostgreSQL and Redis services. 5 | 6 | ## Prerequisites 7 | 8 | - [Git](https://git-scm.com/downloads) 9 | - [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) 10 | - [Go](https://golang.org/doc/install) (1.24.0 or later) 11 | 12 | ## Setup Steps 13 | 14 | ### 1. Clone the Repository 15 | 16 | ```bash 17 | git clone https://github.com/ImGajeed76/discountdb-api.git 18 | cd discountdb-api 19 | ``` 20 | 21 | ### 2. Update Environment Variables and Compose File 22 | 23 | Copy the `.env.example` file to `.env`: 24 | 25 | ```bash 26 | cp .env.example .env 27 | ``` 28 | 29 | Check and update all the environment variables in the `.env` file. 30 | 31 | If you just want a quick local setup you can use this `.env` file: 32 | 33 | ```bash 34 | DB_USERNAME = root 35 | DB_PASSWORD = root 36 | DB_HOST = localhost 37 | DB_PORT = 25060 38 | DB_DATABASE = discountdb 39 | DB_SSL_MODE = disable 40 | 41 | REDIS_USERNAME = default 42 | REDIS_PASSWORD = root 43 | REDIS_HOST = localhost 44 | REDIS_PORT = 25061 45 | REDIS_USE_TLS = false 46 | ``` 47 | 48 | Update the following in the `docker-compose.yml` file: 49 | 50 | ```yaml 51 | POSTGRES_USER: [ DB_USERNAME ] 52 | POSTGRES_PASSWORD: [ DB_PASSWORD ] 53 | POSTGRES_DB: [ DB_DATABASE ] 54 | 55 | ports: 56 | - "[DB_PORT]:5432" 57 | ``` 58 | 59 | ```yaml 60 | command: redis-server --requirepass [REDIS_PASSWORD] 61 | 62 | ports: 63 | - "[REDIS_PORT]:6379" 64 | ``` 65 | 66 | ### 3. Start the Services 67 | 68 | Start the services using Docker Compose: 69 | 70 | ```bash 71 | docker-compose up -d 72 | ``` 73 | 74 | This will spin up Redis and PostgreSQL containers. 75 | 76 | ### 4. Run the API Locally 77 | 78 | Once Redis and PostgreSQL are running via Docker Compose, you can run the API: 79 | 80 | ```bash 81 | go run cmd/api/main.go 82 | ``` 83 | -------------------------------------------------------------------------------- /internal/handlers/coupons/get_tags.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "encoding/json" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | "log" 10 | ) 11 | 12 | func GetTagsResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.TagResponse, error) { 13 | // redis cache 14 | key := "tags" 15 | 16 | var response models.TagResponse 17 | if rdb != nil { 18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil { 19 | if err := json.Unmarshal([]byte(cached), &response); err == nil { 20 | return &response, nil 21 | } 22 | // If unmarshal fails, just log and continue to fetch fresh data 23 | log.Printf("Failed to unmarshal cached data: %v", err) 24 | } 25 | } 26 | 27 | // Get tags if not in cache 28 | tags, err := couponRepo.GetTags(c.Context()) 29 | if err != nil { 30 | log.Printf("Failed to get tags: %v", err) 31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get tags"}) 32 | } 33 | 34 | // Set cache 35 | if rdb != nil { 36 | if cached, err := json.Marshal(tags); err == nil { 37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil { 38 | log.Printf("Failed to cache response: %v", err) 39 | } 40 | } else { 41 | log.Printf("Failed to marshal response for caching: %v", err) 42 | } 43 | } 44 | 45 | return tags, nil 46 | } 47 | 48 | // GetTags godoc 49 | // @Summary Get all tags 50 | // @Description Retrieve a list of all tags 51 | // @Tags tags 52 | // @Produce json 53 | // @Success 200 {object} models.TagResponse 54 | // @Failure 500 {object} models.ErrorResponse 55 | // @Router /coupons/tags [get] 56 | func GetTags(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 57 | tags, err := GetTagsResponse(c, couponRepo, rdb) 58 | 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return c.JSON(tags) 64 | } 65 | -------------------------------------------------------------------------------- /internal/handlers/coupons/get_by_id.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "encoding/json" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | "log" 10 | ) 11 | 12 | // GetCouponByID godoc 13 | // @Summary Get coupon by ID 14 | // @Description Retrieve a single coupon by its ID 15 | // @Tags coupons 16 | // @Accept json 17 | // @Produce json 18 | // @Param id path int true "Coupon ID" 19 | // @Success 200 {object} models.Coupon 20 | // @Failure 404 {object} models.ErrorResponse 21 | // @Failure 500 {object} models.ErrorResponse 22 | // @Router /coupons/{id} [get] 23 | func GetCouponByID(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 24 | id, err := c.ParamsInt("id") 25 | if err != nil { 26 | return c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{Message: "Invalid coupon ID"}) 27 | } 28 | 29 | // redis cache 30 | key := "coupon:id:" + string(id) 31 | 32 | var response fiber.Map 33 | if rdb != nil { 34 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil { 35 | if err := json.Unmarshal([]byte(cached), &response); err == nil { 36 | return c.JSON(response) 37 | } 38 | // If unmarshal fails, just log and continue to fetch fresh data 39 | log.Printf("Failed to unmarshal cached data: %v", err) 40 | } 41 | } 42 | 43 | // Get coupon by ID if not in cache 44 | coupon, err := couponRepo.GetByID(c.Context(), int64(id)) 45 | if err != nil { 46 | log.Printf("Failed to get coupon: %v", err) 47 | return c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get coupon"}) 48 | } 49 | 50 | // Set cache 51 | if rdb != nil { 52 | if cached, err := json.Marshal(coupon); err == nil { 53 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil { 54 | log.Printf("Failed to cache response: %v", err) 55 | } 56 | } else { 57 | log.Printf("Failed to marshal response for caching: %v", err) 58 | } 59 | } 60 | 61 | return c.JSON(coupon) 62 | } 63 | -------------------------------------------------------------------------------- /internal/handlers/coupons/get_regions.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "encoding/json" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | "log" 10 | ) 11 | 12 | func GetRegionsResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.RegionResponse, error) { 13 | // redis cache 14 | key := "regions" 15 | 16 | var response models.RegionResponse 17 | if rdb != nil { 18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil { 19 | if err := json.Unmarshal([]byte(cached), &response); err == nil { 20 | return &response, nil 21 | } 22 | // If unmarshal fails, just log and continue to fetch fresh data 23 | log.Printf("Failed to unmarshal cached data: %v", err) 24 | } 25 | } 26 | 27 | // Get regions if not in cache 28 | regions, err := couponRepo.GetRegions(c.Context()) 29 | if err != nil { 30 | log.Printf("Failed to get regions: %v", err) 31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get regions"}) 32 | } 33 | 34 | // Set cache 35 | if rdb != nil { 36 | if cached, err := json.Marshal(regions); err == nil { 37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil { 38 | log.Printf("Failed to cache response: %v", err) 39 | } 40 | } else { 41 | log.Printf("Failed to marshal response for caching: %v", err) 42 | } 43 | } 44 | 45 | return regions, nil 46 | } 47 | 48 | // GetRegions godoc 49 | // @Summary Get all regions 50 | // @Description Retrieve a list of all regions 51 | // @Tags regions 52 | // @Produce json 53 | // @Success 200 {object} models.RegionResponse 54 | // @Failure 500 {object} models.ErrorResponse 55 | // @Router /coupons/regions [get] 56 | func GetRegions(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 57 | regions, err := GetRegionsResponse(c, couponRepo, rdb) 58 | 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return c.JSON(regions) 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module discountdb-api 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/gofiber/contrib/swagger v1.2.0 7 | github.com/gofiber/fiber/v2 v2.52.9 8 | github.com/joho/godotenv v1.5.1 9 | github.com/lib/pq v1.10.9 10 | github.com/redis/go-redis/v9 v9.7.3 11 | github.com/swaggo/swag v1.16.4 12 | ) 13 | 14 | require ( 15 | github.com/KyleBanks/depth v1.2.1 // indirect 16 | github.com/andybalholm/brotli v1.1.0 // indirect 17 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 18 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 20 | github.com/go-openapi/analysis v0.21.4 // indirect 21 | github.com/go-openapi/errors v0.20.4 // indirect 22 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 23 | github.com/go-openapi/jsonreference v0.21.0 // indirect 24 | github.com/go-openapi/loads v0.21.2 // indirect 25 | github.com/go-openapi/runtime v0.26.2 // indirect 26 | github.com/go-openapi/spec v0.21.0 // indirect 27 | github.com/go-openapi/strfmt v0.21.8 // indirect 28 | github.com/go-openapi/swag v0.23.0 // indirect 29 | github.com/go-openapi/validate v0.22.3 // indirect 30 | github.com/google/uuid v1.6.0 // indirect 31 | github.com/josharian/intern v1.0.0 // indirect 32 | github.com/klauspost/compress v1.17.9 // indirect 33 | github.com/mailru/easyjson v0.9.0 // indirect 34 | github.com/mattn/go-colorable v0.1.13 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/mattn/go-runewidth v0.0.16 // indirect 37 | github.com/mitchellh/mapstructure v1.5.0 // indirect 38 | github.com/oklog/ulid v1.3.1 // indirect 39 | github.com/rivo/uniseg v0.2.0 // indirect 40 | github.com/valyala/bytebufferpool v1.0.0 // indirect 41 | github.com/valyala/fasthttp v1.51.0 // indirect 42 | github.com/valyala/tcplisten v1.0.0 // indirect 43 | go.mongodb.org/mongo-driver v1.13.1 // indirect 44 | golang.org/x/sys v0.28.0 // indirect 45 | golang.org/x/tools v0.28.0 // indirect 46 | gopkg.in/yaml.v2 v2.4.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | -------------------------------------------------------------------------------- /internal/jobs/score_updates.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "log" 7 | "time" 8 | ) 9 | 10 | type ScoreUpdater struct { 11 | db *sql.DB 12 | batchSize int 13 | interval time.Duration 14 | done chan bool 15 | } 16 | 17 | func NewScoreUpdater(db *sql.DB, batchSize int, interval time.Duration) *ScoreUpdater { 18 | return &ScoreUpdater{ 19 | db: db, 20 | batchSize: batchSize, 21 | interval: interval, 22 | done: make(chan bool), 23 | } 24 | } 25 | 26 | func (s *ScoreUpdater) updateScores() error { 27 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) 28 | defer cancel() 29 | 30 | // Get total count of coupons that need updating 31 | var totalToUpdate int 32 | err := s.db.QueryRowContext(ctx, ` 33 | SELECT COUNT(*) 34 | FROM coupons 35 | WHERE last_score_update IS NULL 36 | OR last_score_update < CURRENT_TIMESTAMP - INTERVAL '1 hour' 37 | `).Scan(&totalToUpdate) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | // No coupons need updating 43 | if totalToUpdate == 0 { 44 | return nil 45 | } 46 | 47 | // Calculate number of batches needed 48 | batches := (totalToUpdate + s.batchSize - 1) / s.batchSize 49 | 50 | // Process all batches 51 | for i := 0; i < batches; i++ { 52 | _, err := s.db.ExecContext(ctx, "SELECT update_materialized_scores_batch($1)", s.batchSize) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // Small sleep between batches to reduce database load 58 | time.Sleep(100 * time.Millisecond) 59 | } 60 | 61 | // Log success 62 | log.Printf("Updated scores for %d coupons", totalToUpdate) 63 | 64 | return nil 65 | } 66 | 67 | func (s *ScoreUpdater) Start() { 68 | go func() { 69 | ticker := time.NewTicker(s.interval) 70 | defer ticker.Stop() 71 | 72 | for { 73 | select { 74 | case <-ticker.C: 75 | if err := s.updateScores(); err != nil { 76 | log.Printf("Error updating scores: %v", err) 77 | } 78 | case <-s.done: 79 | return 80 | } 81 | } 82 | }() 83 | } 84 | 85 | func (s *ScoreUpdater) Stop() { 86 | s.done <- true 87 | } 88 | -------------------------------------------------------------------------------- /internal/handlers/coupons/get_merchants.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "encoding/json" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | "log" 10 | ) 11 | 12 | func GetMerchantsResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.MerchantResponse, error) { 13 | // redis cache 14 | key := "merchants" 15 | 16 | var response models.MerchantResponse 17 | if rdb != nil { 18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil { 19 | if err := json.Unmarshal([]byte(cached), &response); err == nil { 20 | return &response, nil 21 | } 22 | // If unmarshal fails, just log and continue to fetch fresh data 23 | log.Printf("Failed to unmarshal cached data: %v", err) 24 | } 25 | } 26 | 27 | // Get merchants if not in cache 28 | merchants, err := couponRepo.GetMerchants(c.Context()) 29 | if err != nil { 30 | log.Printf("Failed to get merchants: %v", err) 31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get merchants"}) 32 | } 33 | 34 | // Set cache 35 | if rdb != nil { 36 | if cached, err := json.Marshal(merchants); err == nil { 37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil { 38 | log.Printf("Failed to cache response: %v", err) 39 | } 40 | } else { 41 | log.Printf("Failed to marshal response for caching: %v", err) 42 | } 43 | } 44 | 45 | return merchants, nil 46 | } 47 | 48 | // GetMerchants godoc 49 | // @Summary Get all merchants 50 | // @Description Retrieve a list of all merchants 51 | // @Tags merchants 52 | // @Produce json 53 | // @Success 200 {object} models.MerchantResponse 54 | // @Failure 500 {object} models.ErrorResponse 55 | // @Router /coupons/merchants [get] 56 | func GetMerchants(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 57 | merchants, err := GetMerchantsResponse(c, couponRepo, rdb) 58 | 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return c.JSON(merchants) 64 | } 65 | -------------------------------------------------------------------------------- /internal/handlers/coupons/get_categories.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "encoding/json" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | "log" 10 | ) 11 | 12 | func GetCategoriesResponse(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.CategoriesResponse, error) { 13 | // redis cache 14 | key := "coupons" 15 | 16 | var response models.CategoriesResponse 17 | if rdb != nil { 18 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil { 19 | if err := json.Unmarshal([]byte(cached), &response); err == nil { 20 | return &response, nil 21 | } 22 | // If unmarshal fails, just log and continue to fetch fresh data 23 | log.Printf("Failed to unmarshal cached data: %v", err) 24 | } 25 | } 26 | 27 | // Get categories if not in cache 28 | categories, err := couponRepo.GetCategories(c.Context()) 29 | if err != nil { 30 | log.Printf("Failed to get categories: %v", err) 31 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get categories"}) 32 | } 33 | 34 | // Set cache 35 | if rdb != nil { 36 | if cached, err := json.Marshal(categories); err == nil { 37 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil { 38 | log.Printf("Failed to cache response: %v", err) 39 | } 40 | } else { 41 | log.Printf("Failed to marshal response for caching: %v", err) 42 | } 43 | } 44 | 45 | return categories, nil 46 | } 47 | 48 | // GetCategories godoc 49 | // @Summary Get all categories 50 | // @Description Retrieve a list of all categories 51 | // @Tags categories 52 | // @Produce json 53 | // @Success 200 {object} models.CategoriesResponse 54 | // @Failure 500 {object} models.ErrorResponse 55 | // @Router /coupons/categories [get] 56 | func GetCategories(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 57 | categories, err := GetCategoriesResponse(c, couponRepo, rdb) 58 | 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return c.JSON(categories) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "discountdb-api/internal/config" 6 | "discountdb-api/internal/database" 7 | "discountdb-api/internal/jobs" 8 | "discountdb-api/internal/routes" 9 | "github.com/gofiber/contrib/swagger" 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/gofiber/fiber/v2/middleware/cors" 12 | "github.com/gofiber/fiber/v2/middleware/logger" 13 | "github.com/redis/go-redis/v9" 14 | "log" 15 | "time" 16 | ) 17 | 18 | // @title DiscountDB API 19 | // @version 1.0 20 | // @description This is the DiscountDB API documentation 21 | // @termsOfService http://swagger.io/terms/ 22 | // @host api.discountdb.ch 23 | // @BasePath /api/v1 24 | func main() { 25 | // Load configuration 26 | cfg, err := config.LoadConfig() 27 | if err != nil { 28 | log.Fatalf("Failed to load config: %v", err) 29 | } 30 | 31 | // Initialize database 32 | db, err := database.NewPostgresDB(cfg) 33 | if err != nil { 34 | log.Fatalf("Failed to initialize database: %v", err) 35 | } 36 | defer func(db *sql.DB) { 37 | err := db.Close() 38 | if err != nil { 39 | log.Fatalf("Failed to close database connection: %v", err) 40 | } 41 | }(db) 42 | log.Printf("Successfully connected to database: %s", cfg.DBName) 43 | 44 | // Initialize redis 45 | rdb, err := database.NewRedisClient(cfg) 46 | if err != nil { 47 | log.Fatalf("Failed to initialize redis: %v", err) 48 | } 49 | defer func(rdb *redis.Client) { 50 | err := rdb.Close() 51 | if err != nil { 52 | log.Fatalf("Failed to close redis connection: %v", err) 53 | } 54 | }(rdb) 55 | log.Printf("Successfully connected to redis: %s", cfg.REDISHost) 56 | 57 | // Initialize Cron Jobs 58 | scoreUpdate := jobs.NewScoreUpdater(db, 1000, 1*time.Hour) 59 | scoreUpdate.Start() 60 | 61 | // Initialize Fiber app 62 | app := fiber.New(fiber.Config{ 63 | AppName: "DiscountDB API v1.0", 64 | }) 65 | 66 | app.Use(logger.New()) 67 | 68 | app.Use(swagger.New(swagger.Config{ 69 | BasePath: "/api/v1/", 70 | FilePath: "./docs/swagger.json", 71 | Path: "docs", 72 | Title: "DiscountDB API v1.0", 73 | })) 74 | 75 | app.Use(cors.New(cors.Config{ 76 | AllowOrigins: "*", 77 | })) 78 | 79 | routes.SetupRoutes(app, db, rdb) 80 | 81 | log.Fatal(app.Listen(":3000")) 82 | } 83 | -------------------------------------------------------------------------------- /internal/handlers/syrup/post_valid.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | import ( 4 | "discountdb-api/internal/handlers/coupons" 5 | "discountdb-api/internal/models" 6 | "discountdb-api/internal/models/syrup" 7 | "encoding/json" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/redis/go-redis/v9" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | func PostCouponVote(ctx *fiber.Ctx, rdb *redis.Client, dir string) error { 15 | vote := models.VoteBody{ 16 | Dir: dir, 17 | } 18 | 19 | if id, err := strconv.Atoi(ctx.Params("id")); err != nil || id < 1 { 20 | return ctx.Status(fiber.StatusBadRequest).JSON( 21 | syrup.ErrorResponse{ 22 | Error: "InvalidID", 23 | Message: "Invalid coupon ID", 24 | }, 25 | ) 26 | } else { 27 | vote.ID = int64(id) 28 | } 29 | 30 | voteQueue := coupons.VoteQueue{ 31 | ID: vote.ID, 32 | Timestamp: time.Now(), 33 | VoteType: vote.Dir, 34 | } 35 | 36 | queueJSON, err := json.Marshal(voteQueue) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | err = rdb.RPush(ctx.Context(), "vote_queue", queueJSON).Err() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | return ctx.JSON(syrup.Success{ 47 | Success: "Coupon successfully reported as valid", 48 | }) 49 | } 50 | 51 | // PostCouponValid godoc 52 | // @Summary Report Valid Coupon 53 | // @Description Report that a coupon code was successfully used 54 | // @Tags syrup 55 | // @Produce json 56 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication" 57 | // @Param id path string true "The ID of the coupon" 58 | // @Success 200 {object} syrup.Success "Successful response" 59 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window" 60 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window" 61 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)" 62 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request" 63 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized" 64 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests" 65 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)" 66 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error" 67 | // @Router /syrup/coupons/valid/{id} [post] 68 | func PostCouponValid(ctx *fiber.Ctx, rdb *redis.Client) error { 69 | return PostCouponVote(ctx, rdb, "up") 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiscountDB API 🚀 2 | 3 | Backend API service for DiscountDB - the open-source coupon and discount database. 4 | 5 | ## Technology Stack 💻 6 | 7 | - **Language**: Go 8 | - **Cache**: Redis 9 | - **Database**: PostgreSQL 10 | - **API Documentation**: Swagger/OpenAPI 11 | 12 | ## Prerequisites 13 | 14 | - Go 1.21+ 15 | - Docker and Docker Compose 16 | - [swag](https://github.com/swaggo/swag) (for API documentation) 17 | 18 | ## Getting Started 🛠️ 19 | 20 | ### 1. Clone the repository 21 | 22 | ```bash 23 | git clone https://github.com/ImGajeed76/discountdb-api.git 24 | cd discountdb-api 25 | ``` 26 | 27 | ### 2. Install dependencies 28 | 29 | ```bash 30 | go mod download 31 | ``` 32 | 33 | ### 3. Set up environment variables 34 | 35 | ```bash 36 | cp .env.example .env 37 | ``` 38 | 39 | Configure the following in your `.env`: 40 | 41 | - PostgreSQL connection details 42 | - Redis connection details 43 | 44 | ### 4. Generate Swagger documentation 45 | 46 | ```bash 47 | # Install swag if you haven't already 48 | go install github.com/swaggo/swag/cmd/swag@latest 49 | ``` 50 | 51 | ```bash 52 | swag init -g ./cmd/api/main.go 53 | ``` 54 | 55 | ### 5. Run the API using Docker Compose 56 | 57 | To simplify the setup of Redis and PostgreSQL, use the provided `docker-compose.yml` file: 58 | 59 | 1. Start the services using Docker Compose: 60 | 61 | ```bash 62 | docker-compose up -d 63 | ``` 64 | 65 | This will spin up Redis and PostgreSQL containers. 66 | 67 | ### 6. Run the API locally 68 | 69 | Once Redis and PostgreSQL are running via Docker Compose, you can run the API: 70 | 71 | ```bash 72 | go run cmd/api/main.go 73 | ``` 74 | 75 | ## Troubleshooting 76 | 77 | - Ensure Docker is running and the containers are healthy (`docker ps` to check their status). 78 | - If ports 5432 or 6379 are already in use, update the `docker-compose.yml` file to use different host ports. 79 | 80 | ## Stopping the Docker Services 81 | 82 | To stop the services: 83 | 84 | ```bash 85 | docker-compose down 86 | ``` 87 | 88 | This will stop and remove the containers. 89 | 90 | ## License 📜 91 | 92 | This project is licensed under the GNU General Public License v3 (GPL-3.0). See the [LICENSE](LICENSE) file for details. 93 | 94 | ## Related Projects 🔗 95 | 96 | - [DiscountDB Frontend](https://github.com/ImGajeed76/discountdb) - Main repository containing the SvelteKit frontend 97 | 98 | ## Contributing 🤝 99 | 100 | 1. Fork the repository 101 | 2. Create a feature branch 102 | 3. Commit your changes 103 | 4. Push to the branch 104 | 5. Submit a pull request 105 | 106 | ## Support 💬 107 | 108 | For questions and support: 109 | 110 | - Open an [issue](https://github.com/ImGajeed76/discountdb-api/issues) 111 | - Visit the [main project repository](https://github.com/ImGajeed76/discountdb) 112 | -------------------------------------------------------------------------------- /internal/handlers/coupons/post_vote.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "context" 5 | "discountdb-api/internal/models" 6 | "discountdb-api/internal/repositories" 7 | "encoding/json" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/redis/go-redis/v9" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | type VoteQueue struct { 15 | ID int64 `json:"id"` 16 | Timestamp time.Time `json:"timestamp"` 17 | VoteType string `json:"vote_type"` 18 | } 19 | 20 | // PostVote godoc 21 | // @Summary Vote on a coupon 22 | // @Description Vote on a coupon by ID 23 | // @Tags votes 24 | // @Produce json 25 | // @Param dir path string true "Vote direction (up or down)" 26 | // @Param id path string true "Coupon ID" 27 | // @Success 200 {object} models.Success 28 | // @Failure 400 {object} models.ErrorResponse 29 | // @Router /coupons/vote/{dir}/{id} [post] 30 | func PostVote(c *fiber.Ctx, rdb *redis.Client) error { 31 | // Get vote direction 32 | dir := c.Params("dir") 33 | if dir != "up" && dir != "down" { 34 | return c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 35 | Message: "Invalid vote direction", 36 | }) 37 | } 38 | 39 | vote := models.VoteBody{ 40 | Dir: dir, 41 | } 42 | 43 | if id, err := strconv.Atoi(c.Params("id")); err != nil || id < 1 { 44 | return c.Status(fiber.StatusBadRequest).JSON( 45 | models.ErrorResponse{ 46 | Message: "Invalid coupon ID", 47 | }, 48 | ) 49 | } else { 50 | vote.ID = int64(id) 51 | } 52 | 53 | voteQueue := VoteQueue{ 54 | ID: vote.ID, 55 | Timestamp: time.Now(), 56 | VoteType: vote.Dir, 57 | } 58 | 59 | queueJSON, err := json.Marshal(voteQueue) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | err = rdb.RPush(c.Context(), "vote_queue", queueJSON).Err() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return c.JSON(models.Success{ 70 | Message: "Vote successfully added to queue", 71 | }) 72 | } 73 | 74 | func ProcessVoteQueue(ctx context.Context, couponRepo *repositories.CouponRepository, rdb *redis.Client, batchSize int) error { 75 | for { 76 | // Get votes batch 77 | results, err := rdb.LRange(ctx, "vote_queue", 0, int64(batchSize-1)).Result() 78 | if err != nil { 79 | return err 80 | } 81 | if len(results) == 0 { 82 | time.Sleep(5 * time.Second) 83 | continue 84 | } 85 | 86 | upVotes := []models.Vote{} 87 | downVotes := []models.Vote{} 88 | 89 | // Parse votes 90 | for _, result := range results { 91 | var voteQueue VoteQueue 92 | if err := json.Unmarshal([]byte(result), &voteQueue); err != nil { 93 | continue 94 | } 95 | 96 | vote := models.Vote{ID: voteQueue.ID, Timestamp: voteQueue.Timestamp} 97 | if voteQueue.VoteType == "up" { 98 | upVotes = append(upVotes, vote) 99 | } else { 100 | downVotes = append(downVotes, vote) 101 | } 102 | } 103 | 104 | // Process votes 105 | if len(upVotes) > 0 { 106 | if err := couponRepo.BatchAddVotes(ctx, upVotes, "up"); err != nil { 107 | return err 108 | } 109 | } 110 | if len(downVotes) > 0 { 111 | if err := couponRepo.BatchAddVotes(ctx, downVotes, "down"); err != nil { 112 | return err 113 | } 114 | } 115 | 116 | // Remove processed votes 117 | rdb.LTrim(ctx, "vote_queue", int64(len(results)), -1) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /internal/models/coupon.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type DiscountType string 6 | 7 | const ( 8 | PercentageOff DiscountType = "PERCENTAGE_OFF" 9 | FixedAmount DiscountType = "FIXED_AMOUNT" 10 | BOGO DiscountType = "BOGO" 11 | FreeShipping DiscountType = "FREE_SHIPPING" 12 | ) 13 | 14 | type Coupon struct { 15 | // Required Information 16 | ID int64 `json:"id"` 17 | CreatedAt time.Time `json:"created_at"` 18 | Code string `json:"code"` 19 | Title string `json:"title"` 20 | Description string `json:"description"` 21 | DiscountValue float64 `json:"discount_value"` 22 | DiscountType DiscountType `json:"discount_type"` 23 | MerchantName string `json:"merchant_name"` 24 | MerchantURL string `json:"merchant_url"` 25 | 26 | // Optional Validity Information 27 | StartDate *time.Time `json:"start_date,omitempty"` 28 | EndDate *time.Time `json:"end_date,omitempty"` 29 | TermsConditions string `json:"terms_conditions,omitempty"` 30 | MinimumPurchaseAmount float64 `json:"minimum_purchase_amount,omitempty"` 31 | MaximumDiscountAmount float64 `json:"maximum_discount_amount,omitempty"` 32 | 33 | // Voting Information 34 | UpVotes TimestampArray `json:"up_votes"` 35 | DownVotes TimestampArray `json:"down_votes"` 36 | 37 | // Metadata 38 | Categories []string `json:"categories,omitempty"` 39 | Tags []string `json:"tags,omitempty"` 40 | Regions []string `json:"regions,omitempty"` // countries/regions where valid 41 | StoreType string `json:"store_type,omitempty"` // "online", "in_store", "both" 42 | 43 | // Score calculated by db 44 | MaterializedScore float64 `json:"score"` 45 | LastScoreUpdate *time.Time `json:"-"` // not exposed to API 46 | } 47 | 48 | type CouponsSearchResponse struct { 49 | Data []Coupon `json:"data" example:[{"id":1,"title":"Discount","description":"Get 10% off","score":5,"created_at":"2021-01-01T00:00:00Z"}]` 50 | Total int `json:"total" example:"100"` 51 | Limit int `json:"limit" example:"10"` 52 | Offset int `json:"offset" example:"0"` 53 | } 54 | 55 | type CouponsSearchParams struct { 56 | SearchString string `json:"search_string" example:"discount"` 57 | SortBy string `json:"sort_by" example:"newest" enums:"newest,oldest,high_score,low_score"` 58 | Limit int `json:"limit" example:"10" minimum:"1"` 59 | Offset int `json:"offset" example:"0" minimum:"0"` 60 | } 61 | 62 | type CouponCreateRequest struct { 63 | // Required Information 64 | Code string `json:"code"` 65 | Title string `json:"title"` 66 | Description string `json:"description"` 67 | DiscountValue float64 `json:"discount_value"` 68 | DiscountType DiscountType `json:"discount_type"` 69 | MerchantName string `json:"merchant_name"` 70 | MerchantURL string `json:"merchant_url"` 71 | 72 | // Optional Validity Information 73 | StartDate *time.Time `json:"start_date,omitempty"` 74 | EndDate *time.Time `json:"end_date,omitempty"` 75 | TermsConditions string `json:"terms_conditions,omitempty"` 76 | MinimumPurchaseAmount float64 `json:"minimum_purchase_amount,omitempty"` 77 | MaximumDiscountAmount float64 `json:"maximum_discount_amount,omitempty"` 78 | 79 | // Metadata 80 | Categories []string `json:"categories,omitempty"` 81 | Tags []string `json:"tags,omitempty"` 82 | Regions []string `json:"regions,omitempty"` // countries/regions where valid 83 | StoreType string `json:"store_type,omitempty"` // "online", "in_store", "both" 84 | } 85 | 86 | type CouponCreateResponse struct { 87 | ID int64 `json:"id"` 88 | CreatedAt time.Time `json:"created_at"` 89 | MaterializedScore float64 `json:"score"` 90 | } 91 | -------------------------------------------------------------------------------- /internal/handlers/syrup/get_coupons.go: -------------------------------------------------------------------------------- 1 | package syrup 2 | 3 | import ( 4 | "discountdb-api/internal/handlers/coupons" 5 | "discountdb-api/internal/models/syrup" 6 | "discountdb-api/internal/repositories" 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/redis/go-redis/v9" 9 | "strconv" 10 | ) 11 | 12 | // GetCoupons godoc 13 | // @Summary List Coupons 14 | // @Description Returns a paginated list of coupons for a specific domain 15 | // @Tags syrup 16 | // @Produce json 17 | // @Param X-Syrup-API-Key header string false "Optional API key for authentication" 18 | // @Param domain query string true "The website domain to fetch coupons for" 19 | // @Param limit query int false "Maximum number of coupons to return" minimum(1) default(20) maximum(100) 20 | // @Param offset query int false "Number of coupons to skip" minimum(0) default(0) 21 | // @Success 200 {object} syrup.CouponList "Successful response" 22 | // @Header 200 {string} X-RateLimit-Limit "The maximum number of requests allowed per time window" 23 | // @Header 200 {string} X-RateLimit-Remaining "The number of requests remaining in the time window" 24 | // @Header 200 {string} X-RateLimit-Reset "The time when the rate limit window resets (Unix timestamp)" 25 | // @Failure 400 {object} syrup.ErrorResponse "Bad Request" 26 | // @Failure 401 {object} syrup.ErrorResponse "Unauthorized" 27 | // @Failure 429 {object} syrup.ErrorResponse "Too Many Requests" 28 | // @Header 429 {integer} X-RateLimit-RetryAfter "Time to wait before retrying (seconds)" 29 | // @Failure 500 {object} syrup.ErrorResponse "Internal Server Error" 30 | // @Router /syrup/coupons [get] 31 | func GetCoupons(ctx *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 32 | params := repositories.SearchParams{ 33 | Limit: 20, 34 | Offset: 0, 35 | SearchIn: []string{ 36 | "merchant_url", 37 | }, 38 | } 39 | 40 | params.SearchString = ctx.Query("domain") 41 | params.SortBy = repositories.SortByHighScore 42 | 43 | if limitStr := ctx.Query("limitStr"); limitStr != "" { 44 | limit, err := strconv.Atoi(limitStr) 45 | if err != nil { 46 | return ctx.Status(fiber.StatusBadRequest).JSON( 47 | syrup.ErrorResponse{ 48 | Error: "InvalidLimit", 49 | Message: "Invalid limit value", 50 | }, 51 | ) 52 | } 53 | if limit < 1 { 54 | return ctx.Status(fiber.StatusBadRequest).JSON( 55 | syrup.ErrorResponse{ 56 | Error: "InvalidLimit", 57 | Message: "Limit must be greater than 0", 58 | }, 59 | ) 60 | } 61 | if limit > 100 { 62 | return ctx.Status(fiber.StatusBadRequest).JSON( 63 | syrup.ErrorResponse{ 64 | Error: "InvalidLimit", 65 | Message: "Limit must be less than or equal to 100", 66 | }, 67 | ) 68 | } 69 | params.Limit = limit 70 | } 71 | 72 | if offsetStr := ctx.Query("offset"); offsetStr != "" { 73 | offset, err := strconv.Atoi(offsetStr) 74 | if err != nil { 75 | return ctx.Status(fiber.StatusBadRequest).JSON( 76 | syrup.ErrorResponse{ 77 | Error: "InvalidOffset", 78 | Message: "Invalid offset value", 79 | }, 80 | ) 81 | } 82 | if offset < 0 { 83 | return ctx.Status(fiber.StatusBadRequest).JSON( 84 | syrup.ErrorResponse{ 85 | Error: "InvalidOffset", 86 | Message: "Offset must be greater than or equal to 0", 87 | }, 88 | ) 89 | } 90 | params.Offset = offset 91 | } 92 | 93 | // Search for coupons 94 | response, err := coupons.SearchCoupons(params, ctx, couponRepo, rdb) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | // Remap response to syrup.CouponList 100 | merchantName := "N/A" 101 | 102 | couponList := syrup.CouponList{ 103 | Total: response.Total, 104 | } 105 | 106 | for _, coupon := range response.Data { 107 | if merchantName == "N/A" && coupon.MerchantName != "" { 108 | merchantName = coupon.MerchantName 109 | } 110 | 111 | couponList.Coupons = append(couponList.Coupons, syrup.Coupon{ 112 | ID: strconv.FormatInt(coupon.ID, 10), 113 | Code: coupon.Code, 114 | Title: coupon.Title, 115 | Description: coupon.Description, 116 | Score: coupon.MaterializedScore, 117 | }) 118 | } 119 | 120 | couponList.MerchantName = merchantName 121 | 122 | return ctx.JSON(couponList) 123 | } 124 | -------------------------------------------------------------------------------- /internal/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "discountdb-api/internal/handlers" 7 | "discountdb-api/internal/handlers/coupons" 8 | "discountdb-api/internal/handlers/syrup" 9 | "discountdb-api/internal/middleware" 10 | "discountdb-api/internal/repositories" 11 | "fmt" 12 | "github.com/gofiber/fiber/v2" 13 | "github.com/redis/go-redis/v9" 14 | "log" 15 | "time" 16 | ) 17 | 18 | func SetupRoutes(app *fiber.App, db *sql.DB, rdb *redis.Client) { 19 | ctx := context.Background() 20 | api := app.Group("/api/v1") 21 | 22 | // Middlewares 23 | defaultRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{ 24 | Max: 100, 25 | Window: time.Minute, 26 | Redis: rdb, 27 | KeyPrefix: "ratelimit:", 28 | }) 29 | 30 | singleVoteRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{ 31 | Max: 1, 32 | Window: 10 * time.Minute, 33 | Redis: rdb, 34 | KeyPrefix: "singlevotelimit:", 35 | KeyFunc: func(c *fiber.Ctx) string { 36 | return fmt.Sprintf("%s:%s", c.IP(), c.Params("id")) 37 | }, 38 | }) 39 | 40 | voteRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{ 41 | Max: 10, 42 | Window: 10 * time.Minute, 43 | Redis: rdb, 44 | KeyPrefix: "votelimit:", 45 | KeyFunc: func(c *fiber.Ctx) string { 46 | return fmt.Sprintf("%s:%s", c.IP(), c.Params("id")) 47 | }, 48 | }) 49 | 50 | createCouponRateLimiter := middleware.NewRateLimiter(middleware.RateLimiterConfig{ 51 | Max: 2, 52 | Window: 10 * time.Minute, 53 | Redis: rdb, 54 | KeyPrefix: "createcouponlimit:", 55 | }) 56 | 57 | // Default route 58 | app.Get("/", func(c *fiber.Ctx) error { 59 | return c.SendString("API is running") // Or redirect to docs/API info 60 | }) 61 | 62 | // Health check endpoint 63 | api.Get("/health", handlers.HealthCheck) 64 | 65 | // Coupon endpoints 66 | couponRepo := repositories.NewCouponRepository(db) 67 | if err := couponRepo.CreateTable(ctx); err != nil { 68 | log.Fatalf("Failed to create coupon table: %v", err) 69 | } 70 | 71 | api.Post("/coupons", createCouponRateLimiter, func(ctx *fiber.Ctx) error { 72 | return coupons.PostCoupon(ctx, couponRepo, rdb) 73 | }) 74 | api.Get("/coupons/search", defaultRateLimiter, func(ctx *fiber.Ctx) error { 75 | return coupons.GetCoupons(ctx, couponRepo, rdb) 76 | }) 77 | api.Get("/coupons/merchants", defaultRateLimiter, func(ctx *fiber.Ctx) error { 78 | return coupons.GetMerchants(ctx, couponRepo, rdb) 79 | }) 80 | api.Get("/coupons/categories", defaultRateLimiter, func(ctx *fiber.Ctx) error { 81 | return coupons.GetCategories(ctx, couponRepo, rdb) 82 | }) 83 | api.Get("/coupons/tags", defaultRateLimiter, func(ctx *fiber.Ctx) error { 84 | return coupons.GetTags(ctx, couponRepo, rdb) 85 | }) 86 | api.Get("/coupons/regions", defaultRateLimiter, func(ctx *fiber.Ctx) error { 87 | return coupons.GetRegions(ctx, couponRepo, rdb) 88 | }) 89 | api.Post("/coupons/vote/:dir/:id", voteRateLimiter, singleVoteRateLimiter, func(ctx *fiber.Ctx) error { 90 | return coupons.PostVote(ctx, rdb) 91 | }) 92 | // This has to be the last route to avoid conflicts 93 | api.Get("/coupons/:id", defaultRateLimiter, func(ctx *fiber.Ctx) error { 94 | return coupons.GetCouponByID(ctx, couponRepo, rdb) 95 | }) 96 | 97 | // Start processing vote queue 98 | go func() { 99 | if err := coupons.ProcessVoteQueue(context.Background(), couponRepo, rdb, 100); err != nil { 100 | log.Printf("Vote processor error: %v", err) 101 | } 102 | }() 103 | 104 | // Syrup Endpoint 105 | api.Get("/syrup/version", syrup.GetVersionInfo) 106 | api.Get("/syrup/coupons", defaultRateLimiter, func(ctx *fiber.Ctx) error { 107 | return syrup.GetCoupons(ctx, couponRepo, rdb) 108 | }) 109 | api.Post("/syrup/coupons/valid/:id", voteRateLimiter, singleVoteRateLimiter, func(ctx *fiber.Ctx) error { 110 | return syrup.PostCouponValid(ctx, rdb) 111 | }) 112 | api.Post("/syrup/coupons/invalid/:id", voteRateLimiter, singleVoteRateLimiter, func(ctx *fiber.Ctx) error { 113 | return syrup.PostCouponInvalid(ctx, rdb) 114 | }) 115 | api.Get("/syrup/merchants", defaultRateLimiter, func(ctx *fiber.Ctx) error { 116 | return syrup.GetMerchants(ctx, couponRepo, rdb) 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /internal/handlers/coupons/post_coupon.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/redis/go-redis/v9" 8 | "log" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | func ValidateCouponRequest(c *fiber.Ctx) (*models.CouponCreateRequest, error) { 14 | var coupon models.CouponCreateRequest 15 | 16 | if err := c.BodyParser(&coupon); err != nil { 17 | log.Printf("Error parsing create coupon request body: %v", err) 18 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 19 | Message: "Invalid request payload", 20 | }) 21 | } 22 | 23 | // Validate coupon fields 24 | if coupon.Code == "" { 25 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 26 | Message: "Coupon code is required", 27 | }) 28 | } 29 | 30 | if coupon.Title == "" { 31 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 32 | Message: "Coupon title is required", 33 | }) 34 | } 35 | 36 | if coupon.Description == "" { 37 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 38 | Message: "Coupon description is required", 39 | }) 40 | } 41 | 42 | if coupon.MerchantName == "" { 43 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 44 | Message: "Merchant name is required", 45 | }) 46 | } 47 | 48 | if coupon.MerchantURL == "" { 49 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 50 | Message: "Merchant URL is required", 51 | }) 52 | } 53 | 54 | coupon.MerchantURL = strings.TrimPrefix(coupon.MerchantURL, "https://") 55 | coupon.MerchantURL = strings.TrimPrefix(coupon.MerchantURL, "http://") 56 | 57 | if coupon.DiscountValue == 0 && coupon.DiscountType != models.FreeShipping && coupon.DiscountType != models.BOGO { 58 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 59 | Message: "Discount value is required", 60 | }) 61 | } 62 | 63 | if coupon.DiscountType != models.PercentageOff && 64 | coupon.DiscountType != models.FixedAmount && 65 | coupon.DiscountType != models.FreeShipping && 66 | coupon.DiscountType != models.BOGO { 67 | return nil, c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{ 68 | Message: "Invalid discount type", 69 | }) 70 | } 71 | 72 | return &coupon, nil 73 | } 74 | 75 | // PostCoupon godoc 76 | // @Summary Create a new coupon 77 | // @Description Create a new coupon 78 | // @Tags coupons 79 | // @Accept json 80 | // @Produce json 81 | // @Param coupon body models.CouponCreateRequest true "CouponCreateRequest object" 82 | // @Success 200 {object} models.CouponCreateResponse 83 | // @Failure 400 {object} models.ErrorResponse "Bad Request" 84 | // @Failure 500 {object} models.ErrorResponse "Internal Server Error" 85 | // @Router /coupons [post] 86 | func PostCoupon(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 87 | couponRequest, err := ValidateCouponRequest(c) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // Create coupon 93 | coupon := models.Coupon{ 94 | ID: 0, 95 | CreatedAt: time.Now(), 96 | Code: couponRequest.Code, 97 | Title: couponRequest.Title, 98 | Description: couponRequest.Description, 99 | DiscountValue: couponRequest.DiscountValue, 100 | DiscountType: couponRequest.DiscountType, 101 | MerchantName: couponRequest.MerchantName, 102 | MerchantURL: couponRequest.MerchantURL, 103 | StartDate: couponRequest.StartDate, 104 | EndDate: couponRequest.EndDate, 105 | TermsConditions: couponRequest.TermsConditions, 106 | MinimumPurchaseAmount: couponRequest.MinimumPurchaseAmount, 107 | MaximumDiscountAmount: couponRequest.MaximumDiscountAmount, 108 | UpVotes: models.TimestampArray{}, 109 | DownVotes: models.TimestampArray{}, 110 | Categories: couponRequest.Categories, 111 | Tags: couponRequest.Tags, 112 | Regions: couponRequest.Regions, 113 | StoreType: couponRequest.StoreType, 114 | MaterializedScore: 0, 115 | LastScoreUpdate: nil, 116 | } 117 | 118 | // Save coupon 119 | if err := couponRepo.Create(c.Context(), &coupon); err != nil { 120 | log.Printf("Failed to create coupon: %v", err) 121 | return c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{ 122 | Message: "Failed to create coupon", 123 | }) 124 | } 125 | 126 | return c.JSON(models.CouponCreateResponse{ 127 | ID: coupon.ID, 128 | MaterializedScore: coupon.MaterializedScore, 129 | CreatedAt: coupon.CreatedAt, 130 | }) 131 | } 132 | -------------------------------------------------------------------------------- /internal/middleware/ratelimiter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "time" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/redis/go-redis/v9" 11 | ) 12 | 13 | // RateLimiterConfig holds the configuration for the rate limiter middleware 14 | type RateLimiterConfig struct { 15 | // Maximum number of requests allowed within the window 16 | Max int 17 | 18 | // Duration of the sliding window 19 | Window time.Duration 20 | 21 | // Redis client instance 22 | Redis *redis.Client 23 | 24 | // Optional prefix for Redis keys 25 | KeyPrefix string 26 | 27 | // Optional response when rate limit is exceeded 28 | LimitExceededHandler fiber.Handler 29 | 30 | // Optional key generation function 31 | KeyFunc func(c *fiber.Ctx) string 32 | } 33 | 34 | // ConfigDefault provides default configuration 35 | var ConfigDefault = RateLimiterConfig{ 36 | Max: 60, 37 | Window: time.Minute, 38 | KeyPrefix: "ratelimit", 39 | LimitExceededHandler: func(c *fiber.Ctx) error { 40 | return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ 41 | "error": "Too many requests", 42 | }) 43 | }, 44 | KeyFunc: func(c *fiber.Ctx) string { 45 | return c.IP() 46 | }, 47 | } 48 | 49 | // validateConfig ensures the configuration is valid 50 | func validateConfig(cfg *RateLimiterConfig) error { 51 | if cfg.Max <= 0 { 52 | return fmt.Errorf("max requests must be greater than 0") 53 | } 54 | if cfg.Window < time.Second { 55 | return fmt.Errorf("window must be at least 1 second") 56 | } 57 | if cfg.Window > 24*time.Hour { 58 | return fmt.Errorf("window must not exceed 24 hours") 59 | } 60 | return nil 61 | } 62 | 63 | // configDefault returns a config with default values for unset fields 64 | func configDefault(config ...RateLimiterConfig) (RateLimiterConfig, error) { 65 | cfg := ConfigDefault 66 | 67 | if len(config) > 0 { 68 | if config[0].Max > 0 { 69 | cfg.Max = config[0].Max 70 | } 71 | if config[0].Window != 0 { 72 | cfg.Window = config[0].Window 73 | } 74 | if config[0].KeyPrefix != "" { 75 | cfg.KeyPrefix = config[0].KeyPrefix 76 | } 77 | if config[0].LimitExceededHandler != nil { 78 | cfg.LimitExceededHandler = config[0].LimitExceededHandler 79 | } 80 | if config[0].KeyFunc != nil { 81 | cfg.KeyFunc = config[0].KeyFunc 82 | } 83 | if config[0].Redis != nil { 84 | cfg.Redis = config[0].Redis 85 | } 86 | } 87 | 88 | if err := validateConfig(&cfg); err != nil { 89 | return cfg, fmt.Errorf("invalid rate limiter config: %w", err) 90 | } 91 | 92 | return cfg, nil 93 | } 94 | 95 | // getRemainingTime calculates the time remaining until the rate limit resets 96 | func getRemainingTime(ctx context.Context, redis *redis.Client, key string) (int64, error) { 97 | ttl, err := redis.TTL(ctx, key).Result() 98 | if err != nil { 99 | return 0, err 100 | } 101 | return int64(math.Ceil(ttl.Seconds())), nil 102 | } 103 | 104 | // NewRateLimiter creates a new rate limiter middleware 105 | func NewRateLimiter(config ...RateLimiterConfig) fiber.Handler { 106 | cfg, err := configDefault(config...) 107 | if err != nil { 108 | panic(err) 109 | } 110 | 111 | if cfg.Redis == nil { 112 | panic("Redis client is required for rate limiter") 113 | } 114 | 115 | // Return the middleware handler 116 | return func(c *fiber.Ctx) error { 117 | // Use request context for proper cancellation 118 | ctx := c.Context() 119 | 120 | // Generate Redis key with proper separator 121 | key := fmt.Sprintf("%s:%s", cfg.KeyPrefix, cfg.KeyFunc(c)) 122 | 123 | // Use Redis MULTI/EXEC for atomic operations 124 | pipe := cfg.Redis.TxPipeline() 125 | 126 | // Increment counter and set expiration in single transaction 127 | incr := pipe.Incr(ctx, key) 128 | pipe.Expire(ctx, key, cfg.Window) 129 | 130 | // Execute transaction 131 | _, err := pipe.Exec(ctx) 132 | if err != nil { 133 | return fmt.Errorf("rate limiter redis error: %w", err) 134 | } 135 | 136 | // Get the current count 137 | count := incr.Val() 138 | 139 | // If this is the first request, ensure the key expires 140 | if count == 1 { 141 | cfg.Redis.Expire(ctx, key, cfg.Window) 142 | } 143 | 144 | // Calculate remaining attempts 145 | remaining := int64(cfg.Max) - count 146 | if remaining < 0 { 147 | remaining = 0 148 | } 149 | 150 | // Get remaining time until reset 151 | remainingTime, err := getRemainingTime(ctx, cfg.Redis, key) 152 | if err != nil { 153 | return fmt.Errorf("error getting remaining time: %w", err) 154 | } 155 | 156 | // Set rate limit headers 157 | c.Set("X-RateLimit-Limit", fmt.Sprintf("%d", cfg.Max)) 158 | c.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining)) 159 | c.Set("X-RateLimit-Reset", fmt.Sprintf("%d", remainingTime)) 160 | 161 | // Check if limit is exceeded 162 | if count > int64(cfg.Max) { 163 | c.Set("X-RateLimit-RetryAfter", fmt.Sprintf("%d", remainingTime)) 164 | return cfg.LimitExceededHandler(c) 165 | } 166 | 167 | return c.Next() 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/handlers/coupons/get_coupons.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "discountdb-api/internal/models" 5 | "discountdb-api/internal/repositories" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/redis/go-redis/v9" 10 | "log" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | const ( 16 | defaultLimit = 10 17 | defaultOffset = 0 18 | cacheExpire = 5 * time.Minute 19 | ) 20 | 21 | // ParseSearchParams extracts and validates search parameters from the request 22 | func ParseSearchParams(c *fiber.Ctx) (repositories.SearchParams, error) { 23 | params := repositories.SearchParams{ 24 | Limit: defaultLimit, 25 | Offset: defaultOffset, 26 | SearchIn: []string{ 27 | "code", 28 | "title", 29 | "description", 30 | "merchant_name", 31 | "merchant_url", 32 | }, 33 | } 34 | 35 | // Parse search string 36 | params.SearchString = c.Query("q") 37 | 38 | // Parse sorting 39 | sortBy := c.Query("sort_by", string(repositories.SortByNewest)) 40 | params.SortBy = repositories.SortBy(sortBy) 41 | if !isValidSortBy(params.SortBy) { 42 | return params, fmt.Errorf("invalid sort_by parameter: %s", sortBy) 43 | } 44 | 45 | // Parse limit 46 | if limitStr := c.Query("limit"); limitStr != "" { 47 | limit, err := strconv.Atoi(limitStr) 48 | if err != nil { 49 | return params, fmt.Errorf("invalid limit parameter: %s", limitStr) 50 | } 51 | if limit < 1 { 52 | return params, fmt.Errorf("limit must be greater than 0") 53 | } 54 | if limit > 100 { 55 | return params, fmt.Errorf("limit must not exceed 100") 56 | } 57 | params.Limit = limit 58 | } 59 | 60 | // Parse offset 61 | if offsetStr := c.Query("offset"); offsetStr != "" { 62 | offset, err := strconv.Atoi(offsetStr) 63 | if err != nil { 64 | return params, fmt.Errorf("invalid offset parameter: %s", offsetStr) 65 | } 66 | if offset < 0 { 67 | return params, fmt.Errorf("offset must be non-negative") 68 | } 69 | params.Offset = offset 70 | } 71 | 72 | return params, nil 73 | } 74 | 75 | func isValidSortBy(s repositories.SortBy) bool { 76 | switch s { 77 | case repositories.SortByNewest, repositories.SortByOldest, repositories.SortByHighScore, repositories.SortByLowScore: 78 | return true 79 | default: 80 | return false 81 | } 82 | } 83 | 84 | func SearchCoupons(params repositories.SearchParams, c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) (*models.CouponsSearchResponse, error) { 85 | // Just use the raw query string as the cache key 86 | key := "coupons:" + string(c.Request().URI().QueryString()) 87 | 88 | // Try to get from cache 89 | var response models.CouponsSearchResponse 90 | if rdb != nil { 91 | if cached, err := rdb.Get(c.Context(), key).Result(); err == nil { 92 | if err := json.Unmarshal([]byte(cached), &response); err == nil { 93 | return &response, nil 94 | } 95 | // If unmarshal fails, just log and continue to fetch fresh data 96 | log.Printf("Failed to unmarshal cached data: %v", err) 97 | } 98 | } 99 | 100 | // Search for coupons if not in cache 101 | coupons, err := couponRepo.Search(c.Context(), params) 102 | if err != nil { 103 | log.Printf("Failed to search coupons: %v", err) 104 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to search coupons"}) 105 | } 106 | 107 | total, err := couponRepo.GetTotalCount(c.Context(), params) 108 | if err != nil { 109 | log.Printf("Failed to get total count: %v", err) 110 | return nil, c.Status(fiber.StatusInternalServerError).JSON(models.ErrorResponse{Message: "Failed to get total count"}) 111 | } 112 | 113 | // Prepare response 114 | response = models.CouponsSearchResponse{ 115 | Data: coupons, 116 | Total: int(total), 117 | Limit: params.Limit, 118 | Offset: params.Offset, 119 | } 120 | 121 | // Cache the response 122 | if rdb != nil { 123 | if cached, err := json.Marshal(response); err == nil { 124 | if err := rdb.Set(c.Context(), key, cached, cacheExpire).Err(); err != nil { 125 | log.Printf("Failed to cache response: %v", err) 126 | } 127 | } else { 128 | log.Printf("Failed to marshal response for caching: %v", err) 129 | } 130 | } 131 | 132 | return &response, nil 133 | } 134 | 135 | // GetCoupons godoc 136 | // @Summary Get coupons with filtering and pagination 137 | // @Description Retrieve a list of coupons with optional search, sorting, and pagination 138 | // @Tags coupons 139 | // @Accept json 140 | // @Produce json 141 | // @Param q query string false "Search query string" 142 | // @Param sort_by query string false "Sort order (newest, oldest, high_score, low_score)" Enums(newest, oldest, high_score, low_score) default(newest) 143 | // @Param limit query integer false "Number of items per page" minimum(1) default(10) 144 | // @Param offset query integer false "Number of items to skip" minimum(0) default(0) 145 | // @Success 200 {object} models.CouponsSearchResponse 146 | // @Failure 400 {object} models.ErrorResponse 147 | // @Failure 500 {object} models.ErrorResponse 148 | // @Router /coupons/search [get] 149 | func GetCoupons(c *fiber.Ctx, couponRepo *repositories.CouponRepository, rdb *redis.Client) error { 150 | // Get url parameters 151 | params, err := ParseSearchParams(c) 152 | if err != nil { 153 | return c.Status(fiber.StatusBadRequest).JSON(models.ErrorResponse{Message: err.Error()}) 154 | } 155 | 156 | // Search for coupons 157 | response, err := SearchCoupons(params, c, couponRepo, rdb) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | return c.JSON(response) 163 | } 164 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 2 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 3 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 4 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 5 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 6 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= 7 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 8 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 9 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 10 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 11 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 12 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 13 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 20 | github.com/go-openapi/analysis v0.21.4 h1:ZDFLvSNxpDaomuCueM0BlSXxpANBlFYiBvr+GXrvIHc= 21 | github.com/go-openapi/analysis v0.21.4/go.mod h1:4zQ35W4neeZTqh3ol0rv/O8JBbka9QyAgQRPp9y3pfo= 22 | github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= 23 | github.com/go-openapi/errors v0.20.4 h1:unTcVm6PispJsMECE3zWgvG4xTiKda1LIR5rCRWLG6M= 24 | github.com/go-openapi/errors v0.20.4/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= 25 | github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= 26 | github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= 27 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 28 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 29 | github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= 30 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 31 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 32 | github.com/go-openapi/loads v0.21.2 h1:r2a/xFIYeZ4Qd2TnGpWDIQNcP80dIaZgf704za8enro= 33 | github.com/go-openapi/loads v0.21.2/go.mod h1:Jq58Os6SSGz0rzh62ptiu8Z31I+OTHqmULx5e/gJbNw= 34 | github.com/go-openapi/runtime v0.26.2 h1:elWyB9MacRzvIVgAZCBJmqTi7hBzU0hlKD4IvfX0Zl0= 35 | github.com/go-openapi/runtime v0.26.2/go.mod h1:O034jyRZ557uJKzngbMDJXkcKJVzXJiymdSfgejrcRw= 36 | github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= 37 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 38 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 39 | github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg= 40 | github.com/go-openapi/strfmt v0.21.8 h1:VYBUoKYRLAlgKDrIxR/I0lKrztDQ0tuTDrbhLVP8Erg= 41 | github.com/go-openapi/strfmt v0.21.8/go.mod h1:adeGTkxE44sPyLk0JV235VQAO/ZXUr8KAzYjclFs3ew= 42 | github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= 43 | github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 44 | github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 45 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 46 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 47 | github.com/go-openapi/validate v0.22.3 h1:KxG9mu5HBRYbecRb37KRCihvGGtND2aXziBAv0NNfyI= 48 | github.com/go-openapi/validate v0.22.3/go.mod h1:kVxh31KbfsxU8ZyoHaDbLBWU5CnMdqBUEtadQ2G4d5M= 49 | github.com/gofiber/contrib/swagger v1.2.0 h1:+tm7mBLFfUxZASQyf1zkvRkAZRZGmnIT+E0Vvj7BZo4= 50 | github.com/gofiber/contrib/swagger v1.2.0/go.mod h1:NRtN6G1RkdpgwFifq4nID/5cdxv410RDH9rUr9fhiqU= 51 | github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= 52 | github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= 53 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 54 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 55 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 56 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 57 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 58 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 59 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 60 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 61 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 62 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 63 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 64 | github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 65 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 66 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 67 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 68 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 69 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 70 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 72 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 73 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 74 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 75 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 76 | github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 77 | github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 78 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 79 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 80 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 81 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 82 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 83 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 84 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 85 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 86 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 87 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 88 | github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 89 | github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 90 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 91 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 92 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= 93 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 94 | github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= 95 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 96 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 97 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 98 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 99 | github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 100 | github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 101 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 102 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 103 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 104 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 105 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 106 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 107 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 108 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 109 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 110 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 111 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 112 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 113 | github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= 114 | github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= 115 | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 116 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 117 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 118 | github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= 119 | github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= 120 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 121 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 122 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 123 | github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= 124 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 125 | github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= 126 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 127 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= 128 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 129 | go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8= 130 | go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= 131 | go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= 132 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 133 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 134 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 135 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 136 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 137 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 138 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 139 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 140 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 141 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 142 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 143 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 144 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 145 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 146 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 147 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 148 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 149 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 150 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 151 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 152 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 153 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 154 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 155 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 156 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 157 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 158 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 159 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 160 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 161 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 162 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 163 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 164 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 165 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 166 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 167 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 168 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 169 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 170 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 171 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 172 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 173 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 174 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 175 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 176 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 177 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 178 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 179 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 180 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 181 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 182 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 183 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 184 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 185 | -------------------------------------------------------------------------------- /internal/repositories/coupon_repo.go: -------------------------------------------------------------------------------- 1 | package repositories 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "discountdb-api/internal/models" 7 | "errors" 8 | "fmt" 9 | "github.com/lib/pq" 10 | "log" 11 | "time" 12 | ) 13 | 14 | type CouponRepository struct { 15 | db *sql.DB 16 | } 17 | 18 | func NewCouponRepository(db *sql.DB) *CouponRepository { 19 | return &CouponRepository{db: db} 20 | } 21 | 22 | // Migration SQL to create the table 23 | const createTableSQL = ` 24 | CREATE TABLE IF NOT EXISTS coupons ( 25 | id BIGSERIAL PRIMARY KEY, 26 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 27 | code VARCHAR(255) NOT NULL, 28 | title VARCHAR(255) NOT NULL, 29 | description TEXT, 30 | discount_value DECIMAL(10,2) NOT NULL, 31 | discount_type VARCHAR(50) NOT NULL, 32 | merchant_name VARCHAR(255) NOT NULL, 33 | merchant_url TEXT NOT NULL, 34 | 35 | start_date TIMESTAMP, 36 | end_date TIMESTAMP, 37 | terms_conditions TEXT, 38 | minimum_purchase_amount DECIMAL(10,2), 39 | maximum_discount_amount DECIMAL(10,2), 40 | 41 | up_votes TIMESTAMP[] DEFAULT ARRAY[]::TIMESTAMP[], 42 | down_votes TIMESTAMP[] DEFAULT ARRAY[]::TIMESTAMP[], 43 | 44 | categories TEXT[] DEFAULT ARRAY[]::TEXT[], 45 | tags TEXT[] DEFAULT ARRAY[]::TEXT[], 46 | regions TEXT[] DEFAULT ARRAY[]::TEXT[], 47 | store_type VARCHAR(50), 48 | 49 | materialized_score DECIMAL(10,4), 50 | last_score_update TIMESTAMP, 51 | 52 | CONSTRAINT valid_discount_type CHECK ( 53 | discount_type IN ('PERCENTAGE_OFF', 'FIXED_AMOUNT', 'BOGO', 'FREE_SHIPPING') 54 | ), 55 | CONSTRAINT valid_store_type CHECK ( 56 | store_type IN ('online', 'in_store', 'both') 57 | ) 58 | ); 59 | 60 | -- Create indexes for common queries 61 | CREATE INDEX IF NOT EXISTS idx_coupons_merchant ON coupons(merchant_name); 62 | CREATE INDEX IF NOT EXISTS idx_coupons_created_at ON coupons(created_at); 63 | CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); 64 | CREATE INDEX IF NOT EXISTS idx_coupons_score ON coupons(materialized_score); 65 | 66 | CREATE OR REPLACE FUNCTION calculate_coupon_score( 67 | p_discount_value DECIMAL, 68 | p_discount_type VARCHAR, 69 | p_maximum_discount_amount DECIMAL, 70 | p_created_at TIMESTAMP, 71 | p_up_votes TIMESTAMP[], 72 | p_down_votes TIMESTAMP[] 73 | ) RETURNS DECIMAL AS $$ 74 | DECLARE 75 | vote_score DECIMAL; 76 | discount_score DECIMAL; 77 | freshness_score DECIMAL; 78 | BEGIN 79 | -- Calculate vote score 80 | SELECT COALESCE( 81 | SUM( 82 | CASE 83 | WHEN age < INTERVAL '1 day' THEN 1.0 84 | WHEN age < INTERVAL '1 week' THEN 0.8 85 | WHEN age < INTERVAL '1 month' THEN 0.6 86 | WHEN age < INTERVAL '6 months' THEN 0.4 87 | ELSE 0.2 88 | END 89 | ), 0) INTO vote_score 90 | FROM ( 91 | SELECT CURRENT_TIMESTAMP - unnest(p_up_votes) as age 92 | ) up; 93 | 94 | SELECT vote_score - COALESCE( 95 | SUM( 96 | CASE 97 | WHEN age < INTERVAL '1 day' THEN 1.0 98 | WHEN age < INTERVAL '1 week' THEN 0.8 99 | WHEN age < INTERVAL '1 month' THEN 0.6 100 | WHEN age < INTERVAL '6 months' THEN 0.4 101 | ELSE 0.2 102 | END 103 | ), 0) INTO vote_score 104 | FROM ( 105 | SELECT CURRENT_TIMESTAMP - unnest(p_down_votes) as age 106 | ) down; 107 | 108 | -- Calculate discount score 109 | discount_score := CASE 110 | WHEN p_discount_type = 'PERCENTAGE_OFF' THEN 111 | LEAST(p_discount_value / 100.0, 1.0) 112 | WHEN p_discount_type = 'FIXED_AMOUNT' THEN 113 | CASE 114 | WHEN p_maximum_discount_amount > 0 THEN 115 | LEAST(p_discount_value / p_maximum_discount_amount, 1.0) 116 | ELSE 117 | LEAST(p_discount_value / 1000.0, 1.0) 118 | END 119 | WHEN p_discount_type IN ('BOGO', 'FREE_SHIPPING') THEN 120 | 0.5 121 | END; 122 | 123 | -- Calculate freshness score 124 | freshness_score := CASE 125 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '1 day' THEN 1.0 126 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '1 week' THEN 0.8 127 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '1 month' THEN 0.6 128 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '3 months' THEN 0.4 129 | WHEN CURRENT_TIMESTAMP - p_created_at < INTERVAL '6 months' THEN 0.2 130 | ELSE 0.1 131 | END; 132 | 133 | -- Return weighted score 134 | RETURN (vote_score * 0.4) + (discount_score * 0.4) + (freshness_score * 0.2); 135 | END; 136 | $$ LANGUAGE plpgsql; 137 | 138 | -- Create batch update function 139 | CREATE OR REPLACE FUNCTION update_materialized_scores_batch(batch_size INT) 140 | RETURNS void AS $$ 141 | BEGIN 142 | WITH coupons_to_update AS ( 143 | SELECT id, discount_value, discount_type, maximum_discount_amount, 144 | created_at, up_votes, down_votes 145 | FROM coupons 146 | WHERE last_score_update IS NULL 147 | OR last_score_update < CURRENT_TIMESTAMP - INTERVAL '1 hour' 148 | ORDER BY last_score_update NULLS FIRST 149 | LIMIT batch_size 150 | FOR UPDATE SKIP LOCKED 151 | ) 152 | UPDATE coupons c 153 | SET materialized_score = calculate_coupon_score( 154 | ct.discount_value, 155 | ct.discount_type, 156 | ct.maximum_discount_amount, 157 | ct.created_at, 158 | ct.up_votes, 159 | ct.down_votes 160 | ), 161 | last_score_update = CURRENT_TIMESTAMP 162 | FROM coupons_to_update ct 163 | WHERE c.id = ct.id; 164 | END; 165 | $$ LANGUAGE plpgsql; 166 | 167 | -- Create trigger function 168 | CREATE OR REPLACE FUNCTION update_coupon_score() RETURNS TRIGGER AS $$ 169 | BEGIN 170 | NEW.materialized_score := calculate_coupon_score( 171 | NEW.discount_value, 172 | NEW.discount_type, 173 | NEW.maximum_discount_amount, 174 | NEW.created_at, 175 | NEW.up_votes, 176 | NEW.down_votes 177 | ); 178 | NEW.last_score_update := CURRENT_TIMESTAMP; 179 | RETURN NEW; 180 | END; 181 | $$ LANGUAGE plpgsql; 182 | 183 | -- Create trigger 184 | DROP TRIGGER IF EXISTS update_score_trigger ON coupons; 185 | CREATE TRIGGER update_score_trigger 186 | BEFORE INSERT OR UPDATE OF discount_value, discount_type, maximum_discount_amount, up_votes, down_votes 187 | ON coupons 188 | FOR EACH ROW 189 | EXECUTE FUNCTION update_coupon_score(); 190 | ` 191 | 192 | func (r *CouponRepository) CreateTable(ctx context.Context) error { 193 | tx, err := r.db.BeginTx(ctx, nil) 194 | if err != nil { 195 | return fmt.Errorf("failed to begin transaction: %v", err) 196 | } 197 | 198 | // Execute the entire script 199 | _, err = tx.ExecContext(ctx, createTableSQL) 200 | if err != nil { 201 | tx.Rollback() 202 | return fmt.Errorf("failed to create tables and functions: %v", err) 203 | } 204 | 205 | return tx.Commit() 206 | } 207 | 208 | func (r *CouponRepository) Create(ctx context.Context, coupon *models.Coupon) error { 209 | const query = ` 210 | INSERT INTO coupons ( 211 | code, title, description, discount_value, discount_type, 212 | merchant_name, merchant_url, start_date, end_date, 213 | terms_conditions, minimum_purchase_amount, maximum_discount_amount, 214 | up_votes, down_votes, categories, tags, regions, store_type 215 | ) VALUES ( 216 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 217 | $13, $14, $15, $16, $17, $18 218 | ) RETURNING id, created_at, materialized_score` 219 | 220 | return r.db.QueryRowContext(ctx, query, 221 | coupon.Code, coupon.Title, coupon.Description, 222 | coupon.DiscountValue, coupon.DiscountType, 223 | coupon.MerchantName, coupon.MerchantURL, 224 | coupon.StartDate, coupon.EndDate, 225 | coupon.TermsConditions, coupon.MinimumPurchaseAmount, 226 | coupon.MaximumDiscountAmount, &coupon.UpVotes, 227 | &coupon.DownVotes, pq.Array(coupon.Categories), 228 | pq.Array(coupon.Tags), pq.Array(coupon.Regions), 229 | coupon.StoreType, 230 | ).Scan(&coupon.ID, &coupon.CreatedAt, &coupon.MaterializedScore) 231 | } 232 | 233 | func (r *CouponRepository) GetByID(ctx context.Context, id int64) (*models.Coupon, error) { 234 | const query = ` 235 | SELECT 236 | id, created_at, code, title, description, 237 | discount_value, discount_type, merchant_name, merchant_url, 238 | start_date, end_date, terms_conditions, 239 | minimum_purchase_amount, maximum_discount_amount, 240 | up_votes, down_votes, categories, tags, 241 | regions, store_type, materialized_score, 242 | last_score_update 243 | FROM coupons 244 | WHERE id = $1` 245 | 246 | coupon := &models.Coupon{} 247 | err := r.db.QueryRowContext(ctx, query, id).Scan( 248 | &coupon.ID, &coupon.CreatedAt, &coupon.Code, 249 | &coupon.Title, &coupon.Description, &coupon.DiscountValue, 250 | &coupon.DiscountType, &coupon.MerchantName, &coupon.MerchantURL, 251 | &coupon.StartDate, &coupon.EndDate, &coupon.TermsConditions, 252 | &coupon.MinimumPurchaseAmount, &coupon.MaximumDiscountAmount, 253 | &coupon.UpVotes, &coupon.DownVotes, 254 | pq.Array(&coupon.Categories), pq.Array(&coupon.Tags), 255 | pq.Array(&coupon.Regions), &coupon.StoreType, 256 | &coupon.MaterializedScore, &coupon.LastScoreUpdate, 257 | ) 258 | if errors.Is(err, sql.ErrNoRows) { 259 | return nil, nil 260 | } 261 | return coupon, err 262 | } 263 | 264 | func (r *CouponRepository) BatchAddVotes(ctx context.Context, votes []models.Vote, voteType string) error { 265 | const upQuery = ` 266 | UPDATE coupons AS c 267 | SET up_votes = CASE 268 | WHEN v.id = c.id THEN array_append(c.up_votes, v.timestamp) 269 | ELSE c.up_votes 270 | END 271 | FROM (SELECT unnest($1::bigint[]) AS id, unnest($2::timestamp[]) AS timestamp) AS v 272 | WHERE c.id = v.id` 273 | 274 | const downQuery = ` 275 | UPDATE coupons AS c 276 | SET down_votes = CASE 277 | WHEN v.id = c.id THEN array_append(c.down_votes, v.timestamp) 278 | ELSE c.down_votes 279 | END 280 | FROM (SELECT unnest($1::bigint[]) AS id, unnest($2::timestamp[]) AS timestamp) AS v 281 | WHERE c.id = v.id` 282 | 283 | ids := make([]int64, len(votes)) 284 | timestamps := make([]time.Time, len(votes)) 285 | for i, v := range votes { 286 | ids[i] = v.ID 287 | timestamps[i] = v.Timestamp 288 | } 289 | 290 | query := upQuery 291 | if voteType == "down" { 292 | query = downQuery 293 | } 294 | 295 | result, err := r.db.ExecContext(ctx, query, pq.Array(ids), pq.Array(timestamps)) 296 | if err != nil { 297 | return err 298 | } 299 | 300 | rows, err := result.RowsAffected() 301 | if err != nil { 302 | return err 303 | } 304 | if rows == 0 { 305 | return sql.ErrNoRows 306 | } 307 | return nil 308 | } 309 | 310 | // --- Search and Filtering --- 311 | 312 | // SortBy represents the available sorting options for coupons 313 | type SortBy string 314 | 315 | const ( 316 | SortByNewest SortBy = "newest" 317 | SortByOldest SortBy = "oldest" 318 | SortByHighScore SortBy = "high_score" 319 | SortByLowScore SortBy = "low_score" 320 | ) 321 | 322 | // SearchParams contains all parameters for searching and filtering coupons 323 | type SearchParams struct { 324 | SearchString string 325 | SortBy SortBy 326 | Limit int 327 | Offset int 328 | SearchIn []string 329 | } 330 | 331 | func (r *CouponRepository) Search(ctx context.Context, params SearchParams) ([]models.Coupon, error) { 332 | // Base query 333 | query := ` 334 | SELECT 335 | id, created_at, code, title, description, 336 | discount_value, discount_type, merchant_name, merchant_url, 337 | start_date, end_date, terms_conditions, 338 | minimum_purchase_amount, maximum_discount_amount, 339 | up_votes, down_votes, categories, tags, 340 | regions, store_type, materialized_score, 341 | last_score_update 342 | FROM coupons 343 | WHERE 1=1 344 | ` 345 | 346 | // Initialize parameters array and counter 347 | queryParams := make([]interface{}, 0) 348 | paramCounter := 1 349 | 350 | // Add search condition if search string is provided 351 | if params.SearchString != "" && len(params.SearchIn) > 0 { 352 | /* 353 | query += fmt.Sprintf(` 354 | AND ( 355 | code ILIKE $%d OR 356 | title ILIKE $%d OR 357 | description ILIKE $%d OR 358 | merchant_name ILIKE $%d OR 359 | merchant_url ILIKE $%d 360 | )`, paramCounter, paramCounter, paramCounter, paramCounter, paramCounter) 361 | */ 362 | query += `AND (` 363 | 364 | for i, searchIn := range params.SearchIn { 365 | if i > 0 { 366 | query += " OR " 367 | } 368 | query += fmt.Sprintf(`%s ILIKE $%d`, searchIn, paramCounter) 369 | } 370 | 371 | query += `)` 372 | 373 | searchTerm := "%" + params.SearchString + "%" 374 | queryParams = append(queryParams, searchTerm) 375 | paramCounter++ 376 | } 377 | 378 | switch params.SortBy { 379 | case SortByNewest: 380 | query += ` ORDER BY created_at DESC` 381 | case SortByOldest: 382 | query += ` ORDER BY created_at ASC` 383 | case SortByHighScore: 384 | query += ` ORDER BY materialized_score DESC` 385 | case SortByLowScore: 386 | query += ` ORDER BY materialized_score ASC` 387 | default: 388 | query += ` ORDER BY created_at DESC` 389 | } 390 | 391 | // Add pagination 392 | query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, paramCounter, paramCounter+1) 393 | queryParams = append(queryParams, params.Limit, params.Offset) 394 | 395 | // Execute query 396 | rows, err := r.db.QueryContext(ctx, query, queryParams...) 397 | if err != nil { 398 | return nil, err 399 | } 400 | defer func(rows *sql.Rows) { 401 | err := rows.Close() 402 | if err != nil { 403 | log.Fatalf("Failed to close rows: %v", err) 404 | } 405 | }(rows) 406 | 407 | // Parse results 408 | var coupons []models.Coupon 409 | for rows.Next() { 410 | coupon := &models.Coupon{} 411 | err := rows.Scan( 412 | &coupon.ID, &coupon.CreatedAt, &coupon.Code, 413 | &coupon.Title, &coupon.Description, &coupon.DiscountValue, 414 | &coupon.DiscountType, &coupon.MerchantName, &coupon.MerchantURL, 415 | &coupon.StartDate, &coupon.EndDate, &coupon.TermsConditions, 416 | &coupon.MinimumPurchaseAmount, &coupon.MaximumDiscountAmount, 417 | &coupon.UpVotes, &coupon.DownVotes, 418 | pq.Array(&coupon.Categories), pq.Array(&coupon.Tags), 419 | pq.Array(&coupon.Regions), &coupon.StoreType, 420 | &coupon.MaterializedScore, &coupon.LastScoreUpdate, 421 | ) 422 | if err != nil { 423 | return nil, err 424 | } 425 | coupons = append(coupons, *coupon) 426 | } 427 | 428 | return coupons, nil 429 | } 430 | 431 | // GetTotalCount returns the total number of coupons matching the search criteria 432 | // This is useful for pagination 433 | func (r *CouponRepository) GetTotalCount(ctx context.Context, params SearchParams) (int64, error) { 434 | query := `SELECT COUNT(*) FROM coupons WHERE 1=1` 435 | queryParams := make([]interface{}, 0) 436 | paramCounter := 1 437 | 438 | if params.SearchString != "" && len(params.SearchIn) > 0 { 439 | query += ` AND (` 440 | 441 | for i, searchIn := range params.SearchIn { 442 | if i > 0 { 443 | query += " OR " 444 | } 445 | query += fmt.Sprintf(`%s ILIKE $%d`, searchIn, paramCounter) 446 | } 447 | 448 | query += `)` 449 | 450 | searchTerm := "%" + params.SearchString + "%" 451 | queryParams = append(queryParams, searchTerm) 452 | paramCounter++ 453 | } 454 | 455 | var count int64 456 | err := r.db.QueryRowContext(ctx, query, queryParams...).Scan(&count) 457 | return count, err 458 | } 459 | 460 | // --- Merchants --- 461 | 462 | func (r *CouponRepository) GetMerchants(ctx context.Context) (*models.MerchantResponse, error) { 463 | query := ` 464 | SELECT 465 | merchant_name, 466 | ARRAY_AGG(DISTINCT merchant_url) as merchant_url 467 | FROM coupons 468 | GROUP BY merchant_name 469 | ORDER BY merchant_name; 470 | ` 471 | rows, err := r.db.QueryContext(ctx, query) 472 | if err != nil { 473 | return nil, err 474 | } 475 | defer func(rows *sql.Rows) { 476 | err := rows.Close() 477 | if err != nil { 478 | log.Fatalf("Failed to close rows: %v", err) 479 | } 480 | }(rows) 481 | 482 | var merchants []models.Merchant 483 | for rows.Next() { 484 | merchant := models.Merchant{} 485 | err := rows.Scan(&merchant.Name, pq.Array(&merchant.Domains)) 486 | if err != nil { 487 | return nil, err 488 | } 489 | merchants = append(merchants, merchant) 490 | } 491 | 492 | merchantResponse := &models.MerchantResponse{ 493 | Total: len(merchants), 494 | Data: merchants, 495 | } 496 | 497 | return merchantResponse, nil 498 | } 499 | 500 | // --- Categories --- 501 | 502 | func (r *CouponRepository) GetCategories(ctx context.Context) (*models.CategoriesResponse, error) { 503 | query := `SELECT DISTINCT unnest(categories) FROM coupons ORDER BY 1;` 504 | rows, err := r.db.QueryContext(ctx, query) 505 | if err != nil { 506 | return nil, err 507 | } 508 | defer func(rows *sql.Rows) { 509 | err := rows.Close() 510 | if err != nil { 511 | log.Fatalf("Failed to close rows: %v", err) 512 | } 513 | }(rows) 514 | 515 | var categories []string 516 | for rows.Next() { 517 | var category string 518 | err := rows.Scan(&category) 519 | if err != nil { 520 | return nil, err 521 | } 522 | categories = append(categories, category) 523 | } 524 | 525 | categoriesResponse := &models.CategoriesResponse{ 526 | Total: len(categories), 527 | Categories: categories, 528 | } 529 | 530 | return categoriesResponse, nil 531 | } 532 | 533 | // --- Tags --- 534 | 535 | func (r *CouponRepository) GetTags(ctx context.Context) (*models.TagResponse, error) { 536 | query := `SELECT DISTINCT unnest(tags) FROM coupons ORDER BY 1;` 537 | rows, err := r.db.QueryContext(ctx, query) 538 | if err != nil { 539 | return nil, err 540 | } 541 | defer func(rows *sql.Rows) { 542 | err := rows.Close() 543 | if err != nil { 544 | log.Fatalf("Failed to close rows: %v", err) 545 | } 546 | }(rows) 547 | 548 | var tags []string 549 | for rows.Next() { 550 | var tag string 551 | err := rows.Scan(&tag) 552 | if err != nil { 553 | return nil, err 554 | } 555 | tags = append(tags, tag) 556 | } 557 | 558 | tagsResponse := &models.TagResponse{ 559 | Total: len(tags), 560 | Tags: tags, 561 | } 562 | 563 | return tagsResponse, nil 564 | } 565 | 566 | // --- Regions --- 567 | 568 | func (r *CouponRepository) GetRegions(ctx context.Context) (*models.RegionResponse, error) { 569 | query := `SELECT DISTINCT unnest(regions) FROM coupons ORDER BY 1;` 570 | rows, err := r.db.QueryContext(ctx, query) 571 | if err != nil { 572 | return nil, err 573 | } 574 | defer func(rows *sql.Rows) { 575 | err := rows.Close() 576 | if err != nil { 577 | log.Fatalf("Failed to close rows: %v", err) 578 | } 579 | }(rows) 580 | 581 | var regions []string 582 | for rows.Next() { 583 | var region string 584 | err := rows.Scan(®ion) 585 | if err != nil { 586 | return nil, err 587 | } 588 | regions = append(regions, region) 589 | } 590 | 591 | regionsResponse := &models.RegionResponse{ 592 | Total: len(regions), 593 | Regions: regions, 594 | } 595 | 596 | return regionsResponse, nil 597 | } 598 | -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: /api/v1 2 | definitions: 3 | models.CategoriesResponse: 4 | properties: 5 | data: 6 | items: 7 | type: string 8 | type: array 9 | total: 10 | example: 2 11 | type: integer 12 | type: object 13 | models.Coupon: 14 | properties: 15 | categories: 16 | description: Metadata 17 | items: 18 | type: string 19 | type: array 20 | code: 21 | type: string 22 | created_at: 23 | type: string 24 | description: 25 | type: string 26 | discount_type: 27 | $ref: '#/definitions/models.DiscountType' 28 | discount_value: 29 | type: number 30 | down_votes: 31 | items: 32 | type: string 33 | type: array 34 | end_date: 35 | type: string 36 | id: 37 | description: Required Information 38 | type: integer 39 | maximum_discount_amount: 40 | type: number 41 | merchant_name: 42 | type: string 43 | merchant_url: 44 | type: string 45 | minimum_purchase_amount: 46 | type: number 47 | regions: 48 | description: countries/regions where valid 49 | items: 50 | type: string 51 | type: array 52 | score: 53 | description: Score calculated by db 54 | type: number 55 | start_date: 56 | description: Optional Validity Information 57 | type: string 58 | store_type: 59 | description: '"online", "in_store", "both"' 60 | type: string 61 | tags: 62 | items: 63 | type: string 64 | type: array 65 | terms_conditions: 66 | type: string 67 | title: 68 | type: string 69 | up_votes: 70 | description: Voting Information 71 | items: 72 | type: string 73 | type: array 74 | type: object 75 | models.CouponCreateRequest: 76 | properties: 77 | categories: 78 | description: Metadata 79 | items: 80 | type: string 81 | type: array 82 | code: 83 | description: Required Information 84 | type: string 85 | description: 86 | type: string 87 | discount_type: 88 | $ref: '#/definitions/models.DiscountType' 89 | discount_value: 90 | type: number 91 | end_date: 92 | type: string 93 | maximum_discount_amount: 94 | type: number 95 | merchant_name: 96 | type: string 97 | merchant_url: 98 | type: string 99 | minimum_purchase_amount: 100 | type: number 101 | regions: 102 | description: countries/regions where valid 103 | items: 104 | type: string 105 | type: array 106 | start_date: 107 | description: Optional Validity Information 108 | type: string 109 | store_type: 110 | description: '"online", "in_store", "both"' 111 | type: string 112 | tags: 113 | items: 114 | type: string 115 | type: array 116 | terms_conditions: 117 | type: string 118 | title: 119 | type: string 120 | type: object 121 | models.CouponCreateResponse: 122 | properties: 123 | created_at: 124 | type: string 125 | id: 126 | type: integer 127 | score: 128 | type: number 129 | type: object 130 | models.CouponsSearchResponse: 131 | properties: 132 | data: 133 | items: 134 | $ref: '#/definitions/models.Coupon' 135 | type: array 136 | limit: 137 | example: 10 138 | type: integer 139 | offset: 140 | example: 0 141 | type: integer 142 | total: 143 | example: 100 144 | type: integer 145 | type: object 146 | models.DiscountType: 147 | enum: 148 | - PERCENTAGE_OFF 149 | - FIXED_AMOUNT 150 | - BOGO 151 | - FREE_SHIPPING 152 | type: string 153 | x-enum-varnames: 154 | - PercentageOff 155 | - FixedAmount 156 | - BOGO 157 | - FreeShipping 158 | models.ErrorResponse: 159 | properties: 160 | message: 161 | example: Internal server error 162 | type: string 163 | type: object 164 | models.HealthCheckResponse: 165 | properties: 166 | status: 167 | example: ok 168 | type: string 169 | version: 170 | example: "1.0" 171 | type: string 172 | type: object 173 | models.Merchant: 174 | properties: 175 | merchant_name: 176 | example: merchant1 177 | type: string 178 | merchant_url: 179 | items: 180 | type: string 181 | type: array 182 | type: object 183 | models.MerchantResponse: 184 | properties: 185 | data: 186 | items: 187 | $ref: '#/definitions/models.Merchant' 188 | type: array 189 | total: 190 | example: 2 191 | type: integer 192 | type: object 193 | models.RegionResponse: 194 | properties: 195 | regions: 196 | items: 197 | type: string 198 | type: array 199 | total: 200 | type: integer 201 | type: object 202 | models.Success: 203 | properties: 204 | message: 205 | example: Success 206 | type: string 207 | type: object 208 | models.TagResponse: 209 | properties: 210 | tags: 211 | items: 212 | type: string 213 | type: array 214 | total: 215 | type: integer 216 | type: object 217 | syrup.Coupon: 218 | properties: 219 | code: 220 | example: DISCOUNT10 221 | type: string 222 | description: 223 | example: Get 10% off 224 | type: string 225 | id: 226 | example: "123" 227 | type: string 228 | score: 229 | example: 5 230 | type: number 231 | title: 232 | example: Discount 233 | type: string 234 | type: object 235 | syrup.CouponList: 236 | properties: 237 | coupons: 238 | items: 239 | $ref: '#/definitions/syrup.Coupon' 240 | type: array 241 | merchant_name: 242 | example: Amazon 243 | type: string 244 | total: 245 | type: integer 246 | type: object 247 | syrup.ErrorResponse: 248 | properties: 249 | error: 250 | example: Internal Server Error 251 | type: string 252 | message: 253 | example: Something went wrong 254 | type: string 255 | type: object 256 | syrup.Merchant: 257 | properties: 258 | domains: 259 | items: 260 | type: string 261 | type: array 262 | merchant_name: 263 | type: string 264 | type: object 265 | syrup.MerchantList: 266 | properties: 267 | merchants: 268 | items: 269 | $ref: '#/definitions/syrup.Merchant' 270 | type: array 271 | total: 272 | type: integer 273 | type: object 274 | syrup.Success: 275 | properties: 276 | success: 277 | example: "true" 278 | type: string 279 | type: object 280 | syrup.VersionInfo: 281 | properties: 282 | provider: 283 | example: DiscountDB 284 | type: string 285 | version: 286 | example: 1.0.0 287 | type: string 288 | type: object 289 | host: api.discountdb.ch 290 | info: 291 | contact: {} 292 | description: This is the DiscountDB API documentation 293 | termsOfService: http://swagger.io/terms/ 294 | title: DiscountDB API 295 | version: "1.0" 296 | paths: 297 | /coupons: 298 | post: 299 | consumes: 300 | - application/json 301 | description: Create a new coupon 302 | parameters: 303 | - description: CouponCreateRequest object 304 | in: body 305 | name: coupon 306 | required: true 307 | schema: 308 | $ref: '#/definitions/models.CouponCreateRequest' 309 | produces: 310 | - application/json 311 | responses: 312 | "200": 313 | description: OK 314 | schema: 315 | $ref: '#/definitions/models.CouponCreateResponse' 316 | "400": 317 | description: Bad Request 318 | schema: 319 | $ref: '#/definitions/models.ErrorResponse' 320 | "500": 321 | description: Internal Server Error 322 | schema: 323 | $ref: '#/definitions/models.ErrorResponse' 324 | summary: Create a new coupon 325 | tags: 326 | - coupons 327 | /coupons/{id}: 328 | get: 329 | consumes: 330 | - application/json 331 | description: Retrieve a single coupon by its ID 332 | parameters: 333 | - description: Coupon ID 334 | in: path 335 | name: id 336 | required: true 337 | type: integer 338 | produces: 339 | - application/json 340 | responses: 341 | "200": 342 | description: OK 343 | schema: 344 | $ref: '#/definitions/models.Coupon' 345 | "404": 346 | description: Not Found 347 | schema: 348 | $ref: '#/definitions/models.ErrorResponse' 349 | "500": 350 | description: Internal Server Error 351 | schema: 352 | $ref: '#/definitions/models.ErrorResponse' 353 | summary: Get coupon by ID 354 | tags: 355 | - coupons 356 | /coupons/categories: 357 | get: 358 | description: Retrieve a list of all categories 359 | produces: 360 | - application/json 361 | responses: 362 | "200": 363 | description: OK 364 | schema: 365 | $ref: '#/definitions/models.CategoriesResponse' 366 | "500": 367 | description: Internal Server Error 368 | schema: 369 | $ref: '#/definitions/models.ErrorResponse' 370 | summary: Get all categories 371 | tags: 372 | - categories 373 | /coupons/merchants: 374 | get: 375 | description: Retrieve a list of all merchants 376 | produces: 377 | - application/json 378 | responses: 379 | "200": 380 | description: OK 381 | schema: 382 | $ref: '#/definitions/models.MerchantResponse' 383 | "500": 384 | description: Internal Server Error 385 | schema: 386 | $ref: '#/definitions/models.ErrorResponse' 387 | summary: Get all merchants 388 | tags: 389 | - merchants 390 | /coupons/regions: 391 | get: 392 | description: Retrieve a list of all regions 393 | produces: 394 | - application/json 395 | responses: 396 | "200": 397 | description: OK 398 | schema: 399 | $ref: '#/definitions/models.RegionResponse' 400 | "500": 401 | description: Internal Server Error 402 | schema: 403 | $ref: '#/definitions/models.ErrorResponse' 404 | summary: Get all regions 405 | tags: 406 | - regions 407 | /coupons/search: 408 | get: 409 | consumes: 410 | - application/json 411 | description: Retrieve a list of coupons with optional search, sorting, and pagination 412 | parameters: 413 | - description: Search query string 414 | in: query 415 | name: q 416 | type: string 417 | - default: newest 418 | description: Sort order (newest, oldest, high_score, low_score) 419 | enum: 420 | - newest 421 | - oldest 422 | - high_score 423 | - low_score 424 | in: query 425 | name: sort_by 426 | type: string 427 | - default: 10 428 | description: Number of items per page 429 | in: query 430 | minimum: 1 431 | name: limit 432 | type: integer 433 | - default: 0 434 | description: Number of items to skip 435 | in: query 436 | minimum: 0 437 | name: offset 438 | type: integer 439 | produces: 440 | - application/json 441 | responses: 442 | "200": 443 | description: OK 444 | schema: 445 | $ref: '#/definitions/models.CouponsSearchResponse' 446 | "400": 447 | description: Bad Request 448 | schema: 449 | $ref: '#/definitions/models.ErrorResponse' 450 | "500": 451 | description: Internal Server Error 452 | schema: 453 | $ref: '#/definitions/models.ErrorResponse' 454 | summary: Get coupons with filtering and pagination 455 | tags: 456 | - coupons 457 | /coupons/tags: 458 | get: 459 | description: Retrieve a list of all tags 460 | produces: 461 | - application/json 462 | responses: 463 | "200": 464 | description: OK 465 | schema: 466 | $ref: '#/definitions/models.TagResponse' 467 | "500": 468 | description: Internal Server Error 469 | schema: 470 | $ref: '#/definitions/models.ErrorResponse' 471 | summary: Get all tags 472 | tags: 473 | - tags 474 | /coupons/vote/{dir}/{id}: 475 | post: 476 | description: Vote on a coupon by ID 477 | parameters: 478 | - description: Vote direction (up or down) 479 | in: path 480 | name: dir 481 | required: true 482 | type: string 483 | - description: Coupon ID 484 | in: path 485 | name: id 486 | required: true 487 | type: string 488 | produces: 489 | - application/json 490 | responses: 491 | "200": 492 | description: OK 493 | schema: 494 | $ref: '#/definitions/models.Success' 495 | "400": 496 | description: Bad Request 497 | schema: 498 | $ref: '#/definitions/models.ErrorResponse' 499 | summary: Vote on a coupon 500 | tags: 501 | - votes 502 | /health: 503 | get: 504 | consumes: 505 | - application/json 506 | description: Get API health status 507 | produces: 508 | - application/json 509 | responses: 510 | "200": 511 | description: OK 512 | schema: 513 | $ref: '#/definitions/models.HealthCheckResponse' 514 | "500": 515 | description: Internal Server Error 516 | schema: 517 | $ref: '#/definitions/models.ErrorResponse' 518 | summary: Health check endpoint 519 | tags: 520 | - health 521 | /syrup/coupons: 522 | get: 523 | description: Returns a paginated list of coupons for a specific domain 524 | parameters: 525 | - description: Optional API key for authentication 526 | in: header 527 | name: X-Syrup-API-Key 528 | type: string 529 | - description: The website domain to fetch coupons for 530 | in: query 531 | name: domain 532 | required: true 533 | type: string 534 | - default: 20 535 | description: Maximum number of coupons to return 536 | in: query 537 | maximum: 100 538 | minimum: 1 539 | name: limit 540 | type: integer 541 | - default: 0 542 | description: Number of coupons to skip 543 | in: query 544 | minimum: 0 545 | name: offset 546 | type: integer 547 | produces: 548 | - application/json 549 | responses: 550 | "200": 551 | description: Successful response 552 | headers: 553 | X-RateLimit-Limit: 554 | description: The maximum number of requests allowed per time window 555 | type: string 556 | X-RateLimit-Remaining: 557 | description: The number of requests remaining in the time window 558 | type: string 559 | X-RateLimit-Reset: 560 | description: The time when the rate limit window resets (Unix timestamp) 561 | type: string 562 | schema: 563 | $ref: '#/definitions/syrup.CouponList' 564 | "400": 565 | description: Bad Request 566 | schema: 567 | $ref: '#/definitions/syrup.ErrorResponse' 568 | "401": 569 | description: Unauthorized 570 | schema: 571 | $ref: '#/definitions/syrup.ErrorResponse' 572 | "429": 573 | description: Too Many Requests 574 | headers: 575 | X-RateLimit-RetryAfter: 576 | description: Time to wait before retrying (seconds) 577 | type: integer 578 | schema: 579 | $ref: '#/definitions/syrup.ErrorResponse' 580 | "500": 581 | description: Internal Server Error 582 | schema: 583 | $ref: '#/definitions/syrup.ErrorResponse' 584 | summary: List Coupons 585 | tags: 586 | - syrup 587 | /syrup/coupons/invalid/{id}: 588 | post: 589 | description: Report that a coupon code failed to work 590 | parameters: 591 | - description: Optional API key for authentication 592 | in: header 593 | name: X-Syrup-API-Key 594 | type: string 595 | - description: The ID of the coupon 596 | in: path 597 | name: id 598 | required: true 599 | type: string 600 | produces: 601 | - application/json 602 | responses: 603 | "200": 604 | description: Successful response 605 | headers: 606 | X-RateLimit-Limit: 607 | description: The maximum number of requests allowed per time window 608 | type: string 609 | X-RateLimit-Remaining: 610 | description: The number of requests remaining in the time window 611 | type: string 612 | X-RateLimit-Reset: 613 | description: The time when the rate limit window resets (Unix timestamp) 614 | type: string 615 | schema: 616 | $ref: '#/definitions/syrup.Success' 617 | "400": 618 | description: Bad Request 619 | schema: 620 | $ref: '#/definitions/syrup.ErrorResponse' 621 | "401": 622 | description: Unauthorized 623 | schema: 624 | $ref: '#/definitions/syrup.ErrorResponse' 625 | "429": 626 | description: Too Many Requests 627 | headers: 628 | X-RateLimit-RetryAfter: 629 | description: Time to wait before retrying (seconds) 630 | type: integer 631 | schema: 632 | $ref: '#/definitions/syrup.ErrorResponse' 633 | "500": 634 | description: Internal Server Error 635 | schema: 636 | $ref: '#/definitions/syrup.ErrorResponse' 637 | summary: Report Invalid Coupon 638 | tags: 639 | - syrup 640 | /syrup/coupons/valid/{id}: 641 | post: 642 | description: Report that a coupon code was successfully used 643 | parameters: 644 | - description: Optional API key for authentication 645 | in: header 646 | name: X-Syrup-API-Key 647 | type: string 648 | - description: The ID of the coupon 649 | in: path 650 | name: id 651 | required: true 652 | type: string 653 | produces: 654 | - application/json 655 | responses: 656 | "200": 657 | description: Successful response 658 | headers: 659 | X-RateLimit-Limit: 660 | description: The maximum number of requests allowed per time window 661 | type: string 662 | X-RateLimit-Remaining: 663 | description: The number of requests remaining in the time window 664 | type: string 665 | X-RateLimit-Reset: 666 | description: The time when the rate limit window resets (Unix timestamp) 667 | type: string 668 | schema: 669 | $ref: '#/definitions/syrup.Success' 670 | "400": 671 | description: Bad Request 672 | schema: 673 | $ref: '#/definitions/syrup.ErrorResponse' 674 | "401": 675 | description: Unauthorized 676 | schema: 677 | $ref: '#/definitions/syrup.ErrorResponse' 678 | "429": 679 | description: Too Many Requests 680 | headers: 681 | X-RateLimit-RetryAfter: 682 | description: Time to wait before retrying (seconds) 683 | type: integer 684 | schema: 685 | $ref: '#/definitions/syrup.ErrorResponse' 686 | "500": 687 | description: Internal Server Error 688 | schema: 689 | $ref: '#/definitions/syrup.ErrorResponse' 690 | summary: Report Valid Coupon 691 | tags: 692 | - syrup 693 | /syrup/merchants: 694 | get: 695 | description: Returns a list of all merchants and their domains 696 | parameters: 697 | - description: Optional API key for authentication 698 | in: header 699 | name: X-Syrup-API-Key 700 | type: string 701 | produces: 702 | - application/json 703 | responses: 704 | "200": 705 | description: Successful response 706 | headers: 707 | X-RateLimit-Limit: 708 | description: The maximum number of requests allowed per time window 709 | type: string 710 | X-RateLimit-Remaining: 711 | description: The number of requests remaining in the time window 712 | type: string 713 | X-RateLimit-Reset: 714 | description: The time when the rate limit window resets (Unix timestamp) 715 | type: string 716 | schema: 717 | $ref: '#/definitions/syrup.MerchantList' 718 | "400": 719 | description: Bad Request 720 | schema: 721 | $ref: '#/definitions/syrup.ErrorResponse' 722 | "401": 723 | description: Unauthorized 724 | schema: 725 | $ref: '#/definitions/syrup.ErrorResponse' 726 | "429": 727 | description: Too Many Requests 728 | headers: 729 | X-RateLimit-RetryAfter: 730 | description: Time to wait before retrying (seconds) 731 | type: integer 732 | schema: 733 | $ref: '#/definitions/syrup.ErrorResponse' 734 | "500": 735 | description: Internal Server Error 736 | schema: 737 | $ref: '#/definitions/syrup.ErrorResponse' 738 | summary: List all Merchants 739 | tags: 740 | - syrup 741 | /syrup/version: 742 | get: 743 | description: Returns information about the API implementation 744 | parameters: 745 | - description: Optional API key for authentication 746 | in: header 747 | name: X-Syrup-API-Key 748 | type: string 749 | produces: 750 | - application/json 751 | responses: 752 | "200": 753 | description: Successful response 754 | headers: 755 | X-RateLimit-Limit: 756 | description: The maximum number of requests allowed per time window 757 | type: string 758 | X-RateLimit-Remaining: 759 | description: The number of requests remaining in the time window 760 | type: string 761 | X-RateLimit-Reset: 762 | description: The time when the rate limit window resets (Unix timestamp) 763 | type: string 764 | schema: 765 | $ref: '#/definitions/syrup.VersionInfo' 766 | "400": 767 | description: Bad Request 768 | schema: 769 | $ref: '#/definitions/syrup.ErrorResponse' 770 | "401": 771 | description: Unauthorized 772 | schema: 773 | $ref: '#/definitions/syrup.ErrorResponse' 774 | "429": 775 | description: Too Many Requests 776 | headers: 777 | X-RateLimit-RetryAfter: 778 | description: Time to wait before retrying (seconds) 779 | type: integer 780 | schema: 781 | $ref: '#/definitions/syrup.ErrorResponse' 782 | "500": 783 | description: Internal Server Error 784 | schema: 785 | $ref: '#/definitions/syrup.ErrorResponse' 786 | summary: Get API Version 787 | tags: 788 | - syrup 789 | swagger: "2.0" 790 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "This is the DiscountDB API documentation", 5 | "title": "DiscountDB API", 6 | "termsOfService": "http://swagger.io/terms/", 7 | "contact": {}, 8 | "version": "1.0" 9 | }, 10 | "host": "api.discountdb.ch", 11 | "basePath": "/api/v1", 12 | "paths": { 13 | "/coupons": { 14 | "post": { 15 | "description": "Create a new coupon", 16 | "consumes": [ 17 | "application/json" 18 | ], 19 | "produces": [ 20 | "application/json" 21 | ], 22 | "tags": [ 23 | "coupons" 24 | ], 25 | "summary": "Create a new coupon", 26 | "parameters": [ 27 | { 28 | "description": "CouponCreateRequest object", 29 | "name": "coupon", 30 | "in": "body", 31 | "required": true, 32 | "schema": { 33 | "$ref": "#/definitions/models.CouponCreateRequest" 34 | } 35 | } 36 | ], 37 | "responses": { 38 | "200": { 39 | "description": "OK", 40 | "schema": { 41 | "$ref": "#/definitions/models.CouponCreateResponse" 42 | } 43 | }, 44 | "400": { 45 | "description": "Bad Request", 46 | "schema": { 47 | "$ref": "#/definitions/models.ErrorResponse" 48 | } 49 | }, 50 | "500": { 51 | "description": "Internal Server Error", 52 | "schema": { 53 | "$ref": "#/definitions/models.ErrorResponse" 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | "/coupons/categories": { 60 | "get": { 61 | "description": "Retrieve a list of all categories", 62 | "produces": [ 63 | "application/json" 64 | ], 65 | "tags": [ 66 | "categories" 67 | ], 68 | "summary": "Get all categories", 69 | "responses": { 70 | "200": { 71 | "description": "OK", 72 | "schema": { 73 | "$ref": "#/definitions/models.CategoriesResponse" 74 | } 75 | }, 76 | "500": { 77 | "description": "Internal Server Error", 78 | "schema": { 79 | "$ref": "#/definitions/models.ErrorResponse" 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | "/coupons/merchants": { 86 | "get": { 87 | "description": "Retrieve a list of all merchants", 88 | "produces": [ 89 | "application/json" 90 | ], 91 | "tags": [ 92 | "merchants" 93 | ], 94 | "summary": "Get all merchants", 95 | "responses": { 96 | "200": { 97 | "description": "OK", 98 | "schema": { 99 | "$ref": "#/definitions/models.MerchantResponse" 100 | } 101 | }, 102 | "500": { 103 | "description": "Internal Server Error", 104 | "schema": { 105 | "$ref": "#/definitions/models.ErrorResponse" 106 | } 107 | } 108 | } 109 | } 110 | }, 111 | "/coupons/regions": { 112 | "get": { 113 | "description": "Retrieve a list of all regions", 114 | "produces": [ 115 | "application/json" 116 | ], 117 | "tags": [ 118 | "regions" 119 | ], 120 | "summary": "Get all regions", 121 | "responses": { 122 | "200": { 123 | "description": "OK", 124 | "schema": { 125 | "$ref": "#/definitions/models.RegionResponse" 126 | } 127 | }, 128 | "500": { 129 | "description": "Internal Server Error", 130 | "schema": { 131 | "$ref": "#/definitions/models.ErrorResponse" 132 | } 133 | } 134 | } 135 | } 136 | }, 137 | "/coupons/search": { 138 | "get": { 139 | "description": "Retrieve a list of coupons with optional search, sorting, and pagination", 140 | "consumes": [ 141 | "application/json" 142 | ], 143 | "produces": [ 144 | "application/json" 145 | ], 146 | "tags": [ 147 | "coupons" 148 | ], 149 | "summary": "Get coupons with filtering and pagination", 150 | "parameters": [ 151 | { 152 | "type": "string", 153 | "description": "Search query string", 154 | "name": "q", 155 | "in": "query" 156 | }, 157 | { 158 | "enum": [ 159 | "newest", 160 | "oldest", 161 | "high_score", 162 | "low_score" 163 | ], 164 | "type": "string", 165 | "default": "newest", 166 | "description": "Sort order (newest, oldest, high_score, low_score)", 167 | "name": "sort_by", 168 | "in": "query" 169 | }, 170 | { 171 | "minimum": 1, 172 | "type": "integer", 173 | "default": 10, 174 | "description": "Number of items per page", 175 | "name": "limit", 176 | "in": "query" 177 | }, 178 | { 179 | "minimum": 0, 180 | "type": "integer", 181 | "default": 0, 182 | "description": "Number of items to skip", 183 | "name": "offset", 184 | "in": "query" 185 | } 186 | ], 187 | "responses": { 188 | "200": { 189 | "description": "OK", 190 | "schema": { 191 | "$ref": "#/definitions/models.CouponsSearchResponse" 192 | } 193 | }, 194 | "400": { 195 | "description": "Bad Request", 196 | "schema": { 197 | "$ref": "#/definitions/models.ErrorResponse" 198 | } 199 | }, 200 | "500": { 201 | "description": "Internal Server Error", 202 | "schema": { 203 | "$ref": "#/definitions/models.ErrorResponse" 204 | } 205 | } 206 | } 207 | } 208 | }, 209 | "/coupons/tags": { 210 | "get": { 211 | "description": "Retrieve a list of all tags", 212 | "produces": [ 213 | "application/json" 214 | ], 215 | "tags": [ 216 | "tags" 217 | ], 218 | "summary": "Get all tags", 219 | "responses": { 220 | "200": { 221 | "description": "OK", 222 | "schema": { 223 | "$ref": "#/definitions/models.TagResponse" 224 | } 225 | }, 226 | "500": { 227 | "description": "Internal Server Error", 228 | "schema": { 229 | "$ref": "#/definitions/models.ErrorResponse" 230 | } 231 | } 232 | } 233 | } 234 | }, 235 | "/coupons/vote/{dir}/{id}": { 236 | "post": { 237 | "description": "Vote on a coupon by ID", 238 | "produces": [ 239 | "application/json" 240 | ], 241 | "tags": [ 242 | "votes" 243 | ], 244 | "summary": "Vote on a coupon", 245 | "parameters": [ 246 | { 247 | "type": "string", 248 | "description": "Vote direction (up or down)", 249 | "name": "dir", 250 | "in": "path", 251 | "required": true 252 | }, 253 | { 254 | "type": "string", 255 | "description": "Coupon ID", 256 | "name": "id", 257 | "in": "path", 258 | "required": true 259 | } 260 | ], 261 | "responses": { 262 | "200": { 263 | "description": "OK", 264 | "schema": { 265 | "$ref": "#/definitions/models.Success" 266 | } 267 | }, 268 | "400": { 269 | "description": "Bad Request", 270 | "schema": { 271 | "$ref": "#/definitions/models.ErrorResponse" 272 | } 273 | } 274 | } 275 | } 276 | }, 277 | "/coupons/{id}": { 278 | "get": { 279 | "description": "Retrieve a single coupon by its ID", 280 | "consumes": [ 281 | "application/json" 282 | ], 283 | "produces": [ 284 | "application/json" 285 | ], 286 | "tags": [ 287 | "coupons" 288 | ], 289 | "summary": "Get coupon by ID", 290 | "parameters": [ 291 | { 292 | "type": "integer", 293 | "description": "Coupon ID", 294 | "name": "id", 295 | "in": "path", 296 | "required": true 297 | } 298 | ], 299 | "responses": { 300 | "200": { 301 | "description": "OK", 302 | "schema": { 303 | "$ref": "#/definitions/models.Coupon" 304 | } 305 | }, 306 | "404": { 307 | "description": "Not Found", 308 | "schema": { 309 | "$ref": "#/definitions/models.ErrorResponse" 310 | } 311 | }, 312 | "500": { 313 | "description": "Internal Server Error", 314 | "schema": { 315 | "$ref": "#/definitions/models.ErrorResponse" 316 | } 317 | } 318 | } 319 | } 320 | }, 321 | "/health": { 322 | "get": { 323 | "description": "Get API health status", 324 | "consumes": [ 325 | "application/json" 326 | ], 327 | "produces": [ 328 | "application/json" 329 | ], 330 | "tags": [ 331 | "health" 332 | ], 333 | "summary": "Health check endpoint", 334 | "responses": { 335 | "200": { 336 | "description": "OK", 337 | "schema": { 338 | "$ref": "#/definitions/models.HealthCheckResponse" 339 | } 340 | }, 341 | "500": { 342 | "description": "Internal Server Error", 343 | "schema": { 344 | "$ref": "#/definitions/models.ErrorResponse" 345 | } 346 | } 347 | } 348 | } 349 | }, 350 | "/syrup/coupons": { 351 | "get": { 352 | "description": "Returns a paginated list of coupons for a specific domain", 353 | "produces": [ 354 | "application/json" 355 | ], 356 | "tags": [ 357 | "syrup" 358 | ], 359 | "summary": "List Coupons", 360 | "parameters": [ 361 | { 362 | "type": "string", 363 | "description": "Optional API key for authentication", 364 | "name": "X-Syrup-API-Key", 365 | "in": "header" 366 | }, 367 | { 368 | "type": "string", 369 | "description": "The website domain to fetch coupons for", 370 | "name": "domain", 371 | "in": "query", 372 | "required": true 373 | }, 374 | { 375 | "maximum": 100, 376 | "minimum": 1, 377 | "type": "integer", 378 | "default": 20, 379 | "description": "Maximum number of coupons to return", 380 | "name": "limit", 381 | "in": "query" 382 | }, 383 | { 384 | "minimum": 0, 385 | "type": "integer", 386 | "default": 0, 387 | "description": "Number of coupons to skip", 388 | "name": "offset", 389 | "in": "query" 390 | } 391 | ], 392 | "responses": { 393 | "200": { 394 | "description": "Successful response", 395 | "schema": { 396 | "$ref": "#/definitions/syrup.CouponList" 397 | }, 398 | "headers": { 399 | "X-RateLimit-Limit": { 400 | "type": "string", 401 | "description": "The maximum number of requests allowed per time window" 402 | }, 403 | "X-RateLimit-Remaining": { 404 | "type": "string", 405 | "description": "The number of requests remaining in the time window" 406 | }, 407 | "X-RateLimit-Reset": { 408 | "type": "string", 409 | "description": "The time when the rate limit window resets (Unix timestamp)" 410 | } 411 | } 412 | }, 413 | "400": { 414 | "description": "Bad Request", 415 | "schema": { 416 | "$ref": "#/definitions/syrup.ErrorResponse" 417 | } 418 | }, 419 | "401": { 420 | "description": "Unauthorized", 421 | "schema": { 422 | "$ref": "#/definitions/syrup.ErrorResponse" 423 | } 424 | }, 425 | "429": { 426 | "description": "Too Many Requests", 427 | "schema": { 428 | "$ref": "#/definitions/syrup.ErrorResponse" 429 | }, 430 | "headers": { 431 | "X-RateLimit-RetryAfter": { 432 | "type": "integer", 433 | "description": "Time to wait before retrying (seconds)" 434 | } 435 | } 436 | }, 437 | "500": { 438 | "description": "Internal Server Error", 439 | "schema": { 440 | "$ref": "#/definitions/syrup.ErrorResponse" 441 | } 442 | } 443 | } 444 | } 445 | }, 446 | "/syrup/coupons/invalid/{id}": { 447 | "post": { 448 | "description": "Report that a coupon code failed to work", 449 | "produces": [ 450 | "application/json" 451 | ], 452 | "tags": [ 453 | "syrup" 454 | ], 455 | "summary": "Report Invalid Coupon", 456 | "parameters": [ 457 | { 458 | "type": "string", 459 | "description": "Optional API key for authentication", 460 | "name": "X-Syrup-API-Key", 461 | "in": "header" 462 | }, 463 | { 464 | "type": "string", 465 | "description": "The ID of the coupon", 466 | "name": "id", 467 | "in": "path", 468 | "required": true 469 | } 470 | ], 471 | "responses": { 472 | "200": { 473 | "description": "Successful response", 474 | "schema": { 475 | "$ref": "#/definitions/syrup.Success" 476 | }, 477 | "headers": { 478 | "X-RateLimit-Limit": { 479 | "type": "string", 480 | "description": "The maximum number of requests allowed per time window" 481 | }, 482 | "X-RateLimit-Remaining": { 483 | "type": "string", 484 | "description": "The number of requests remaining in the time window" 485 | }, 486 | "X-RateLimit-Reset": { 487 | "type": "string", 488 | "description": "The time when the rate limit window resets (Unix timestamp)" 489 | } 490 | } 491 | }, 492 | "400": { 493 | "description": "Bad Request", 494 | "schema": { 495 | "$ref": "#/definitions/syrup.ErrorResponse" 496 | } 497 | }, 498 | "401": { 499 | "description": "Unauthorized", 500 | "schema": { 501 | "$ref": "#/definitions/syrup.ErrorResponse" 502 | } 503 | }, 504 | "429": { 505 | "description": "Too Many Requests", 506 | "schema": { 507 | "$ref": "#/definitions/syrup.ErrorResponse" 508 | }, 509 | "headers": { 510 | "X-RateLimit-RetryAfter": { 511 | "type": "integer", 512 | "description": "Time to wait before retrying (seconds)" 513 | } 514 | } 515 | }, 516 | "500": { 517 | "description": "Internal Server Error", 518 | "schema": { 519 | "$ref": "#/definitions/syrup.ErrorResponse" 520 | } 521 | } 522 | } 523 | } 524 | }, 525 | "/syrup/coupons/valid/{id}": { 526 | "post": { 527 | "description": "Report that a coupon code was successfully used", 528 | "produces": [ 529 | "application/json" 530 | ], 531 | "tags": [ 532 | "syrup" 533 | ], 534 | "summary": "Report Valid Coupon", 535 | "parameters": [ 536 | { 537 | "type": "string", 538 | "description": "Optional API key for authentication", 539 | "name": "X-Syrup-API-Key", 540 | "in": "header" 541 | }, 542 | { 543 | "type": "string", 544 | "description": "The ID of the coupon", 545 | "name": "id", 546 | "in": "path", 547 | "required": true 548 | } 549 | ], 550 | "responses": { 551 | "200": { 552 | "description": "Successful response", 553 | "schema": { 554 | "$ref": "#/definitions/syrup.Success" 555 | }, 556 | "headers": { 557 | "X-RateLimit-Limit": { 558 | "type": "string", 559 | "description": "The maximum number of requests allowed per time window" 560 | }, 561 | "X-RateLimit-Remaining": { 562 | "type": "string", 563 | "description": "The number of requests remaining in the time window" 564 | }, 565 | "X-RateLimit-Reset": { 566 | "type": "string", 567 | "description": "The time when the rate limit window resets (Unix timestamp)" 568 | } 569 | } 570 | }, 571 | "400": { 572 | "description": "Bad Request", 573 | "schema": { 574 | "$ref": "#/definitions/syrup.ErrorResponse" 575 | } 576 | }, 577 | "401": { 578 | "description": "Unauthorized", 579 | "schema": { 580 | "$ref": "#/definitions/syrup.ErrorResponse" 581 | } 582 | }, 583 | "429": { 584 | "description": "Too Many Requests", 585 | "schema": { 586 | "$ref": "#/definitions/syrup.ErrorResponse" 587 | }, 588 | "headers": { 589 | "X-RateLimit-RetryAfter": { 590 | "type": "integer", 591 | "description": "Time to wait before retrying (seconds)" 592 | } 593 | } 594 | }, 595 | "500": { 596 | "description": "Internal Server Error", 597 | "schema": { 598 | "$ref": "#/definitions/syrup.ErrorResponse" 599 | } 600 | } 601 | } 602 | } 603 | }, 604 | "/syrup/merchants": { 605 | "get": { 606 | "description": "Returns a list of all merchants and their domains", 607 | "produces": [ 608 | "application/json" 609 | ], 610 | "tags": [ 611 | "syrup" 612 | ], 613 | "summary": "List all Merchants", 614 | "parameters": [ 615 | { 616 | "type": "string", 617 | "description": "Optional API key for authentication", 618 | "name": "X-Syrup-API-Key", 619 | "in": "header" 620 | } 621 | ], 622 | "responses": { 623 | "200": { 624 | "description": "Successful response", 625 | "schema": { 626 | "$ref": "#/definitions/syrup.MerchantList" 627 | }, 628 | "headers": { 629 | "X-RateLimit-Limit": { 630 | "type": "string", 631 | "description": "The maximum number of requests allowed per time window" 632 | }, 633 | "X-RateLimit-Remaining": { 634 | "type": "string", 635 | "description": "The number of requests remaining in the time window" 636 | }, 637 | "X-RateLimit-Reset": { 638 | "type": "string", 639 | "description": "The time when the rate limit window resets (Unix timestamp)" 640 | } 641 | } 642 | }, 643 | "400": { 644 | "description": "Bad Request", 645 | "schema": { 646 | "$ref": "#/definitions/syrup.ErrorResponse" 647 | } 648 | }, 649 | "401": { 650 | "description": "Unauthorized", 651 | "schema": { 652 | "$ref": "#/definitions/syrup.ErrorResponse" 653 | } 654 | }, 655 | "429": { 656 | "description": "Too Many Requests", 657 | "schema": { 658 | "$ref": "#/definitions/syrup.ErrorResponse" 659 | }, 660 | "headers": { 661 | "X-RateLimit-RetryAfter": { 662 | "type": "integer", 663 | "description": "Time to wait before retrying (seconds)" 664 | } 665 | } 666 | }, 667 | "500": { 668 | "description": "Internal Server Error", 669 | "schema": { 670 | "$ref": "#/definitions/syrup.ErrorResponse" 671 | } 672 | } 673 | } 674 | } 675 | }, 676 | "/syrup/version": { 677 | "get": { 678 | "description": "Returns information about the API implementation", 679 | "produces": [ 680 | "application/json" 681 | ], 682 | "tags": [ 683 | "syrup" 684 | ], 685 | "summary": "Get API Version", 686 | "parameters": [ 687 | { 688 | "type": "string", 689 | "description": "Optional API key for authentication", 690 | "name": "X-Syrup-API-Key", 691 | "in": "header" 692 | } 693 | ], 694 | "responses": { 695 | "200": { 696 | "description": "Successful response", 697 | "schema": { 698 | "$ref": "#/definitions/syrup.VersionInfo" 699 | }, 700 | "headers": { 701 | "X-RateLimit-Limit": { 702 | "type": "string", 703 | "description": "The maximum number of requests allowed per time window" 704 | }, 705 | "X-RateLimit-Remaining": { 706 | "type": "string", 707 | "description": "The number of requests remaining in the time window" 708 | }, 709 | "X-RateLimit-Reset": { 710 | "type": "string", 711 | "description": "The time when the rate limit window resets (Unix timestamp)" 712 | } 713 | } 714 | }, 715 | "400": { 716 | "description": "Bad Request", 717 | "schema": { 718 | "$ref": "#/definitions/syrup.ErrorResponse" 719 | } 720 | }, 721 | "401": { 722 | "description": "Unauthorized", 723 | "schema": { 724 | "$ref": "#/definitions/syrup.ErrorResponse" 725 | } 726 | }, 727 | "429": { 728 | "description": "Too Many Requests", 729 | "schema": { 730 | "$ref": "#/definitions/syrup.ErrorResponse" 731 | }, 732 | "headers": { 733 | "X-RateLimit-RetryAfter": { 734 | "type": "integer", 735 | "description": "Time to wait before retrying (seconds)" 736 | } 737 | } 738 | }, 739 | "500": { 740 | "description": "Internal Server Error", 741 | "schema": { 742 | "$ref": "#/definitions/syrup.ErrorResponse" 743 | } 744 | } 745 | } 746 | } 747 | } 748 | }, 749 | "definitions": { 750 | "models.CategoriesResponse": { 751 | "type": "object", 752 | "properties": { 753 | "data": { 754 | "type": "array", 755 | "items": { 756 | "type": "string" 757 | } 758 | }, 759 | "total": { 760 | "type": "integer", 761 | "example": 2 762 | } 763 | } 764 | }, 765 | "models.Coupon": { 766 | "type": "object", 767 | "properties": { 768 | "categories": { 769 | "description": "Metadata", 770 | "type": "array", 771 | "items": { 772 | "type": "string" 773 | } 774 | }, 775 | "code": { 776 | "type": "string" 777 | }, 778 | "created_at": { 779 | "type": "string" 780 | }, 781 | "description": { 782 | "type": "string" 783 | }, 784 | "discount_type": { 785 | "$ref": "#/definitions/models.DiscountType" 786 | }, 787 | "discount_value": { 788 | "type": "number" 789 | }, 790 | "down_votes": { 791 | "type": "array", 792 | "items": { 793 | "type": "string" 794 | } 795 | }, 796 | "end_date": { 797 | "type": "string" 798 | }, 799 | "id": { 800 | "description": "Required Information", 801 | "type": "integer" 802 | }, 803 | "maximum_discount_amount": { 804 | "type": "number" 805 | }, 806 | "merchant_name": { 807 | "type": "string" 808 | }, 809 | "merchant_url": { 810 | "type": "string" 811 | }, 812 | "minimum_purchase_amount": { 813 | "type": "number" 814 | }, 815 | "regions": { 816 | "description": "countries/regions where valid", 817 | "type": "array", 818 | "items": { 819 | "type": "string" 820 | } 821 | }, 822 | "score": { 823 | "description": "Score calculated by db", 824 | "type": "number" 825 | }, 826 | "start_date": { 827 | "description": "Optional Validity Information", 828 | "type": "string" 829 | }, 830 | "store_type": { 831 | "description": "\"online\", \"in_store\", \"both\"", 832 | "type": "string" 833 | }, 834 | "tags": { 835 | "type": "array", 836 | "items": { 837 | "type": "string" 838 | } 839 | }, 840 | "terms_conditions": { 841 | "type": "string" 842 | }, 843 | "title": { 844 | "type": "string" 845 | }, 846 | "up_votes": { 847 | "description": "Voting Information", 848 | "type": "array", 849 | "items": { 850 | "type": "string" 851 | } 852 | } 853 | } 854 | }, 855 | "models.CouponCreateRequest": { 856 | "type": "object", 857 | "properties": { 858 | "categories": { 859 | "description": "Metadata", 860 | "type": "array", 861 | "items": { 862 | "type": "string" 863 | } 864 | }, 865 | "code": { 866 | "description": "Required Information", 867 | "type": "string" 868 | }, 869 | "description": { 870 | "type": "string" 871 | }, 872 | "discount_type": { 873 | "$ref": "#/definitions/models.DiscountType" 874 | }, 875 | "discount_value": { 876 | "type": "number" 877 | }, 878 | "end_date": { 879 | "type": "string" 880 | }, 881 | "maximum_discount_amount": { 882 | "type": "number" 883 | }, 884 | "merchant_name": { 885 | "type": "string" 886 | }, 887 | "merchant_url": { 888 | "type": "string" 889 | }, 890 | "minimum_purchase_amount": { 891 | "type": "number" 892 | }, 893 | "regions": { 894 | "description": "countries/regions where valid", 895 | "type": "array", 896 | "items": { 897 | "type": "string" 898 | } 899 | }, 900 | "start_date": { 901 | "description": "Optional Validity Information", 902 | "type": "string" 903 | }, 904 | "store_type": { 905 | "description": "\"online\", \"in_store\", \"both\"", 906 | "type": "string" 907 | }, 908 | "tags": { 909 | "type": "array", 910 | "items": { 911 | "type": "string" 912 | } 913 | }, 914 | "terms_conditions": { 915 | "type": "string" 916 | }, 917 | "title": { 918 | "type": "string" 919 | } 920 | } 921 | }, 922 | "models.CouponCreateResponse": { 923 | "type": "object", 924 | "properties": { 925 | "created_at": { 926 | "type": "string" 927 | }, 928 | "id": { 929 | "type": "integer" 930 | }, 931 | "score": { 932 | "type": "number" 933 | } 934 | } 935 | }, 936 | "models.CouponsSearchResponse": { 937 | "type": "object", 938 | "properties": { 939 | "data": { 940 | "type": "array", 941 | "items": { 942 | "$ref": "#/definitions/models.Coupon" 943 | } 944 | }, 945 | "limit": { 946 | "type": "integer", 947 | "example": 10 948 | }, 949 | "offset": { 950 | "type": "integer", 951 | "example": 0 952 | }, 953 | "total": { 954 | "type": "integer", 955 | "example": 100 956 | } 957 | } 958 | }, 959 | "models.DiscountType": { 960 | "type": "string", 961 | "enum": [ 962 | "PERCENTAGE_OFF", 963 | "FIXED_AMOUNT", 964 | "BOGO", 965 | "FREE_SHIPPING" 966 | ], 967 | "x-enum-varnames": [ 968 | "PercentageOff", 969 | "FixedAmount", 970 | "BOGO", 971 | "FreeShipping" 972 | ] 973 | }, 974 | "models.ErrorResponse": { 975 | "type": "object", 976 | "properties": { 977 | "message": { 978 | "type": "string", 979 | "example": "Internal server error" 980 | } 981 | } 982 | }, 983 | "models.HealthCheckResponse": { 984 | "type": "object", 985 | "properties": { 986 | "status": { 987 | "type": "string", 988 | "example": "ok" 989 | }, 990 | "version": { 991 | "type": "string", 992 | "example": "1.0" 993 | } 994 | } 995 | }, 996 | "models.Merchant": { 997 | "type": "object", 998 | "properties": { 999 | "merchant_name": { 1000 | "type": "string", 1001 | "example": "merchant1" 1002 | }, 1003 | "merchant_url": { 1004 | "type": "array", 1005 | "items": { 1006 | "type": "string" 1007 | } 1008 | } 1009 | } 1010 | }, 1011 | "models.MerchantResponse": { 1012 | "type": "object", 1013 | "properties": { 1014 | "data": { 1015 | "type": "array", 1016 | "items": { 1017 | "$ref": "#/definitions/models.Merchant" 1018 | } 1019 | }, 1020 | "total": { 1021 | "type": "integer", 1022 | "example": 2 1023 | } 1024 | } 1025 | }, 1026 | "models.RegionResponse": { 1027 | "type": "object", 1028 | "properties": { 1029 | "regions": { 1030 | "type": "array", 1031 | "items": { 1032 | "type": "string" 1033 | } 1034 | }, 1035 | "total": { 1036 | "type": "integer" 1037 | } 1038 | } 1039 | }, 1040 | "models.Success": { 1041 | "type": "object", 1042 | "properties": { 1043 | "message": { 1044 | "type": "string", 1045 | "example": "Success" 1046 | } 1047 | } 1048 | }, 1049 | "models.TagResponse": { 1050 | "type": "object", 1051 | "properties": { 1052 | "tags": { 1053 | "type": "array", 1054 | "items": { 1055 | "type": "string" 1056 | } 1057 | }, 1058 | "total": { 1059 | "type": "integer" 1060 | } 1061 | } 1062 | }, 1063 | "syrup.Coupon": { 1064 | "type": "object", 1065 | "properties": { 1066 | "code": { 1067 | "type": "string", 1068 | "example": "DISCOUNT10" 1069 | }, 1070 | "description": { 1071 | "type": "string", 1072 | "example": "Get 10% off" 1073 | }, 1074 | "id": { 1075 | "type": "string", 1076 | "example": "123" 1077 | }, 1078 | "score": { 1079 | "type": "number", 1080 | "example": 5 1081 | }, 1082 | "title": { 1083 | "type": "string", 1084 | "example": "Discount" 1085 | } 1086 | } 1087 | }, 1088 | "syrup.CouponList": { 1089 | "type": "object", 1090 | "properties": { 1091 | "coupons": { 1092 | "type": "array", 1093 | "items": { 1094 | "$ref": "#/definitions/syrup.Coupon" 1095 | } 1096 | }, 1097 | "merchant_name": { 1098 | "type": "string", 1099 | "example": "Amazon" 1100 | }, 1101 | "total": { 1102 | "type": "integer" 1103 | } 1104 | } 1105 | }, 1106 | "syrup.ErrorResponse": { 1107 | "type": "object", 1108 | "properties": { 1109 | "error": { 1110 | "type": "string", 1111 | "example": "Internal Server Error" 1112 | }, 1113 | "message": { 1114 | "type": "string", 1115 | "example": "Something went wrong" 1116 | } 1117 | } 1118 | }, 1119 | "syrup.Merchant": { 1120 | "type": "object", 1121 | "properties": { 1122 | "domains": { 1123 | "type": "array", 1124 | "items": { 1125 | "type": "string" 1126 | } 1127 | }, 1128 | "merchant_name": { 1129 | "type": "string" 1130 | } 1131 | } 1132 | }, 1133 | "syrup.MerchantList": { 1134 | "type": "object", 1135 | "properties": { 1136 | "merchants": { 1137 | "type": "array", 1138 | "items": { 1139 | "$ref": "#/definitions/syrup.Merchant" 1140 | } 1141 | }, 1142 | "total": { 1143 | "type": "integer" 1144 | } 1145 | } 1146 | }, 1147 | "syrup.Success": { 1148 | "type": "object", 1149 | "properties": { 1150 | "success": { 1151 | "type": "string", 1152 | "example": "true" 1153 | } 1154 | } 1155 | }, 1156 | "syrup.VersionInfo": { 1157 | "type": "object", 1158 | "properties": { 1159 | "provider": { 1160 | "type": "string", 1161 | "example": "DiscountDB" 1162 | }, 1163 | "version": { 1164 | "type": "string", 1165 | "example": "1.0.0" 1166 | } 1167 | } 1168 | } 1169 | } 1170 | } --------------------------------------------------------------------------------