From 8f6180129051027bb72ea1eef15716c6fa57079c Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Sun, 23 Feb 2025 07:33:51 +0530 Subject: [PATCH 01/15] Done (#3) * Done * Summary in SUMMARY.md file --- .gitignore | 1 + Makefile | 2 + SUMMARY.md | 135 +++++++++++++++ cmd/main.go | 39 +++++ go.mod | 29 ++++ go.sum | 53 ++++++ internal/data/data.go | 274 ++++++++++++++++++++++++++++++ internal/handler/distributer.go | 50 ++++++ internal/handler/init.go | 17 ++ internal/handler/permission.go | 51 ++++++ internal/regions/check.go | 22 +++ internal/regions/init.go | 70 ++++++++ internal/response/create_error.go | 19 +++ internal/response/handle_error.go | 30 ++++ internal/response/response.go | 21 +++ utils/csv_parse.go | 46 +++++ validation/handle_request.go | 62 +++++++ validation/validation.go | 74 ++++++++ 18 files changed, 995 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 SUMMARY.md create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/data/data.go create mode 100644 internal/handler/distributer.go create mode 100644 internal/handler/init.go create mode 100644 internal/handler/permission.go create mode 100644 internal/regions/check.go create mode 100644 internal/regions/init.go create mode 100644 internal/response/create_error.go create mode 100644 internal/response/handle_error.go create mode 100644 internal/response/response.go create mode 100644 utils/csv_parse.go create mode 100644 validation/handle_request.go create mode 100644 validation/validation.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..88d050b19 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +main \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..0a71248c8 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +running: + CompileDaemon -build="go build -o ./cmd/main ./cmd" -command=./cmd/main \ No newline at end of file diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 000000000..d69aa0dca --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,135 @@ +# Distribution Management System + +This project was developed as part of a machine task for a company interview process. It implements a distribution management system with features for managing distributors and their permissions across different regions. + +## Core Features + +- **Distributor Management** + - Add new distributors + - Remove existing distributors + - Create sub-distributor relationships + - Region-based validation using cities.csv data + +- **Permission Control** + - Allow distribution rights + - Revoke distribution rights + - Check distribution permission status + +- **Validation System** + - Region validation against cities.csv database + - Distributor existence verification + +## API Endpoints + +### Distributor Management + +#### 1. Add Distributor +- **Endpoint**: `POST /distributor` +- **Description**: Register a new distributor in the system +- **Request Body**: +```json +{ + "distributor":"distributer_name" +} + ``` +- **Success Response**: 201 Created + +#### 2. Remove Distributor +- **Endpoint**: `DELETE /distributor/:distributor` +- **Description**: Remove an existing distributor from the system +- **Path Parameter**: `distributor` - Name of the distributor +- **Success Response**: 200 OK + +#### 3. Add Sub-Distributor +- **Endpoint**: `POST /distributor/add-sub` +- **Description**: Create a hierarchical relationship between distributors +- **Request Body**: + ```json +{ + "parent_distributor":"distributer_name", + "sub_distributor":"new_sub_distributer_name"" +} + ``` +- **Success Response**: 201 Created + +### Permission Management + +#### 1. Check Distribution Permission +- **Endpoint**: `GET /permission/check` +- **Description**: Verify if a distributor has permission for a specific region +- **Query Parameters**: + - `distributor`: Distributor name + - `region`: Region to check +- **Success Response**: 200 OK with permission status + +#### 2. Allow Distribution +- **Endpoint**: `POST /permission/allow` +- **Description**: Grant distribution rights to a distributor +- **Request Body**: + ```json + { + "distributor": "distributor_name", + "region": "region_name" (Eg: "KLRAI-TN-IN") + } + ``` +- **Success Response**: 200 OK + +#### 3. Disallow Distribution +- **Endpoint**: `POST /permission/disallow` +- **Description**: Revoke distribution rights from a distributor +- **Request Body**: + ```json + { + "distributor": "distributor_name", + "region": "region_name" + } + ``` +- **Success Response**: 200 OK + +## Technical Implementation + +### Implementation Details +- **Clean Architecture Pattern** + - Separation of concerns + - Route handlers in `internal/handler` + - Distribution logics and saving data handled in `internal/data` + - Easy to test and maintain +- **Validation System** + - Region validation using CSV data + - Distributor existence checks + - Permission hierarchy validation + +### Key Components +1. **Route Handlers** (`internal/handler`) + - Handle HTTP requests and responses + - Input validation and sanitization + - Error handling and response formatting + +2. **Business Logic** + - Distributor management logic + - Permission validation and inheritance + - Region validation against CSV data + + +## Technical Notes + +- The system performs validation against a predefined list of cities/regions from cities.csv +- Distributor authentication is simplified (no session management) with distributer name passed in request body +- All operations include validation for distributor existence and permission checks +- The system maintains hierarchical relationships between distributors and sub-distributors + +## Future Improvements + +Potential enhancements that could be added: +1. Proper authentication and session management +2. Database persistence for distributor and permission data (restricted by assignment) +3. Caching layer for frequently accessed data (restricted by assignment) +4. More detailed logging and monitoring +5. Rate limiting for API endpoints +6. Testing(unit and integrated) + +## Proposed Model Improvements + +1. **Enhanced Distribution Model** + - Create separate entities for distributors + - Enable multi-directional distribution relationships \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 000000000..1a60572e1 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "challenge16/internal/handler" + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + + app := fiber.New() + app.Use(logger.New()) + + handler := handler.NewHandler() + + //initialize the routes + { + distributor := app.Group("/distributor") + { + distributor.Post("/", handler.AddDistributor) + distributor.Delete("/:distributor", handler.RemoveDistributor) + distributor.Post("/add-sub", handler.AddSubDistributor) + } + + permission := app.Group("/permission") + { + permission.Get("/check", handler.CheckIfDistributionIsAllowed) + permission.Post("/allow", handler.AllowDistribution) + permission.Post("/disallow", handler.DisallowDistribution) + } + } + + err := app.Listen(fmt.Sprintf(":4010")) + if err != nil { + panic("Couldn't start the server. Error: " + err.Error()) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..c37680c66 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module challenge16 + +go 1.23.2 + +require ( + github.com/go-playground/validator/v10 v10.25.0 + github.com/gofiber/fiber/v2 v2.52.6 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..2cdbff2e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= +github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/data/data.go b/internal/data/data.go new file mode 100644 index 000000000..eb8a7935d --- /dev/null +++ b/internal/data/data.go @@ -0,0 +1,274 @@ +package data + +import ( + "challenge16/internal/regions" + "challenge16/internal/response" + "errors" + "strings" +) + +const ( + allowAll = "allow-all" + denyAll = "deny-all" + custom = "custom" + + COUNTRY = "country" + PROVINCE = "province" + CITY = "city" + + DISTRIBUTOR_NOT_FOUND = "DISTRIBUTOR_NOT_FOUND" + REGION_NOT_FOUND = "REGION_NOT_FOUND" + INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" +) + +var ( + ErrDistributorNotFound = errors.New("distributor not found") + ErrDistributorExists = errors.New("distributor already exists") + + successResponse = response.CreateSuccess(200, "SUCCESS", nil) + createdResponse = response.CreateSuccess(201, "CREATED", nil) +) + +type ( + DataBank map[string]distributorData + + distributorData struct { + permissionDataGlobally + parentDistributor *string + } + + permissionDataGlobally map[string]permissionDataInCountry + + permissionDataInCountry struct { + PermissionType string // "allow-all", "deny-all", "custom" + Inclusions map[string]permissionDataInProvince + Exclusions map[string]permissionDataInProvince + } + + permissionDataInProvince struct { + PermissionType string // "allow-all", "deny-all", "custom" + Inclusions map[string]bool + Exclusions map[string]bool + } +) + +func NewDataBank() DataBank { + return make(DataBank) +} + +func (db *DataBank) MarkInclusion(distributor, regionString string) response.Response { + return markAsIncluded(*db, distributor, regionString) +} + +func markAsIncluded(db DataBank, distributor, regionString string) response.Response { + if _, ok := db[distributor]; !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + } + + countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } + + if regionType == COUNTRY { + db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + PermissionType: allowAll, + } + return successResponse + } + if _, ok := db[distributor].permissionDataGlobally[countryCode]; !ok { + db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + PermissionType: custom, + Inclusions: make(map[string]permissionDataInProvince), + } + } + if regionType == PROVINCE { + db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + PermissionType: allowAll, + } + return successResponse + } + + if _, ok := db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { + db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + PermissionType: custom, + Inclusions: make(map[string]bool), + } + } + + db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = true + return successResponse +} + +func (db *DataBank) MarkExclusion(distributor, regionString string) response.Response { + return markAsExcluded(*db, distributor, regionString) +} + +func markAsExcluded(db DataBank, distributor, regionString string) response.Response { + if _, ok := db[distributor]; !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + } + + countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } + + if regionType == COUNTRY { + db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + PermissionType: denyAll, + } + return successResponse + } + if _, ok := db[distributor].permissionDataGlobally[countryCode]; !ok { + db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + PermissionType: custom, + Inclusions: make(map[string]permissionDataInProvince), + } + } + if regionType == PROVINCE { + db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + PermissionType: denyAll, + } + return successResponse + } + + if _, ok := db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { + db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + PermissionType: custom, + Inclusions: make(map[string]bool), + } + } + + db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = false + + return successResponse +} + +func (db DataBank) getRegionDetails(regionString string) (countryCode, provinceCode, cityCode, regionType string, err error) { + subStrings := strings.Split(regionString, "-") // Splitting the regionString by "-", this is the regionString I am assuming + switch len(subStrings) { + case 1: + countryCode = subStrings[0] + if !regions.CheckCountry(countryCode) { + err = errors.New("country not found") + return + } + regionType = COUNTRY + case 2: + countryCode = subStrings[1] + provinceCode = subStrings[0] + regionType = PROVINCE + if !regions.CheckProvince(countryCode, provinceCode) { + err = errors.New("country/province not found") + return + } + default: + countryCode = subStrings[2] + provinceCode = subStrings[1] + cityCode = subStrings[0] + regionType = CITY + if !regions.CheckCity(countryCode, provinceCode, cityCode) { + err = errors.New("country/province/city not found") + return + } + } + return +} + +func (db DataBank) IsAllowed(distributor, regionString string) response.Response { + countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } + isAllowed, err := db.isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType) + if err != nil { + if err == ErrDistributorNotFound { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, err) + } else { + return response.CreateError(500, INTERNAL_SERVER_ERROR, err) + } + } + + if !isAllowed { + return response.CreateError(200, "DISTRIBUTION_NOT_ALLOWED", nil) + } else { + return response.CreateSuccess(200, "DISTRIBUTION_ALLOWED", nil) + } +} + +func (db DataBank) isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType string) (bool, error) { + permissionData, ok := db[distributor] + if !ok { + return false, ErrDistributorNotFound + } + + if permissionData.parentDistributor != nil { + if allowed, err := db.isAllowedForTheDistributor(*permissionData.parentDistributor, countryCode, provinceCode, cityCode, regionType); err != nil { + return false, err + } else if !allowed { + return false, nil + } + } + + if permissionDataInCountry, exists := permissionData.permissionDataGlobally[countryCode]; exists { + switch permissionDataInCountry.PermissionType { + case allowAll: + return true, nil + case denyAll: + return false, nil + default: + if regionType == COUNTRY { + return false, nil + } + } + if permissionDataInProvince, exists := permissionDataInCountry.Inclusions[provinceCode]; exists { + switch permissionDataInProvince.PermissionType { + case allowAll: + return true, nil + case denyAll: + return false, nil + default: + if regionType == PROVINCE { + return false, nil + } + if permissionDataInProvince.Inclusions[cityCode] { + return true, nil + } + } + } + } + return false, nil +} + +func (db DataBank) AddSubDistributor(subDistributor, parentDistributor string) response.Response { + if _, ok := db[parentDistributor]; !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + } + if _, ok := db[subDistributor]; !ok { + db[subDistributor] = distributorData{ + permissionDataGlobally: make(permissionDataGlobally), + parentDistributor: &parentDistributor, + } + } + return createdResponse +} + +func (db *DataBank) AddDistributor(distributor string) response.Response { + if _, ok := (*db)[distributor]; ok { + return response.CreateError(400, "DISTRIBUTOR_EXISTS", ErrDistributorExists) + } + (*db)[distributor] = distributorData{ + permissionDataGlobally: make(permissionDataGlobally), + parentDistributor: nil, + } + return createdResponse +} + +func (db DataBank) RemoveDistributor(distributor string) response.Response { + if _, ok := db[distributor]; !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + } + delete(db, distributor) + return successResponse +} diff --git a/internal/handler/distributer.go b/internal/handler/distributer.go new file mode 100644 index 000000000..18029b5f5 --- /dev/null +++ b/internal/handler/distributer.go @@ -0,0 +1,50 @@ +package handler + +import ( + "challenge16/internal/response" + "challenge16/validation" + "errors" + + "github.com/gofiber/fiber/v2" +) + +func (h *handler) AddDistributor(c *fiber.Ctx) error { + req := new(struct { + Distributor string `json:"distributor" validate:"required"` + }) + + if ok, err := validation.BindAndValidateJSONRequest(c, req); !ok { + return err + } + + resp := h.databank.AddDistributor(req.Distributor) + return resp.WriteToJSON(c) +} + +func (h *handler) AddSubDistributor(c *fiber.Ctx) error { + req := new(struct { + SubDistributor string `json:"sub_distributor" validate:"required"` + ParentDistributor string `json:"parent_distributor" validate:"required"` + }) + + if ok, err := validation.BindAndValidateJSONRequest(c, req); !ok { + return err + } + + resp := h.databank.AddSubDistributor(req.SubDistributor, req.ParentDistributor) + return resp.WriteToJSON(c) +} + +type Ss struct { + Distributor string `param:"distributor" validate:"required"` +} + +func (h *handler) RemoveDistributor(c *fiber.Ctx) error { + distributor := c.Params("distributor") + if distributor == "" { + return response.CreateError(400, URL_PARAM_MISSING, errors.New("distributor is required")).WriteToJSON(c) + } + + resp := h.databank.RemoveDistributor(distributor) + return resp.WriteToJSON(c) +} diff --git a/internal/handler/init.go b/internal/handler/init.go new file mode 100644 index 000000000..ef79331a9 --- /dev/null +++ b/internal/handler/init.go @@ -0,0 +1,17 @@ +package handler + +import "challenge16/internal/data" + +const ( + URL_PARAM_MISSING = "URL_PARAM_MISSING" +) + +type handler struct { + databank data.DataBank +} + +func NewHandler() *handler { + return &handler{ + databank: data.NewDataBank(), + } +} diff --git a/internal/handler/permission.go b/internal/handler/permission.go new file mode 100644 index 000000000..6cc098a9f --- /dev/null +++ b/internal/handler/permission.go @@ -0,0 +1,51 @@ +package handler + +import ( + "challenge16/validation" + + "github.com/gofiber/fiber/v2" +) + +type SS struct { + Distributor string `query:"distributor" validate:"required"` + RegionString string `query:"region" validate:"required"` +} + +func (h *handler) CheckIfDistributionIsAllowed(c *fiber.Ctx) error { + req := new(SS) + + if ok, err := validation.BindAndValidateURLQueryRequest(c, req); !ok { + return err + } + + resp := h.databank.IsAllowed(req.Distributor, req.RegionString) + return resp.WriteToJSON(c) +} + +func (h *handler) AllowDistribution(c *fiber.Ctx) error { + req := new(struct { + RegionString string `json:"region" validate:"required"` + Distributor string `json:"distributor" validate:"required"` + }) + + if ok, err := validation.BindAndValidateJSONRequest(c, req); !ok { + return err + } + + resp := h.databank.MarkInclusion(req.Distributor, req.RegionString) + return resp.WriteToJSON(c) +} + +func (h *handler) DisallowDistribution(c *fiber.Ctx) error { + req := new(struct { + RegionString string `json:"region" validate:"required"` + Distributor string `json:"distributor" validate:"required"` + }) + + if ok, err := validation.BindAndValidateJSONRequest(c, req); !ok { + return err + } + + resp := h.databank.MarkExclusion(req.Distributor, req.RegionString) + return resp.WriteToJSON(c) +} diff --git a/internal/regions/check.go b/internal/regions/check.go new file mode 100644 index 000000000..d63bff8a0 --- /dev/null +++ b/internal/regions/check.go @@ -0,0 +1,22 @@ +package regions + +func CheckCountry(countryCode string) bool { + _, ok := Countries[countryCode] + return ok +} + +func CheckProvince(countryCode, provinceCode string) bool { + if CheckCountry(countryCode) == false { + return false + } + _, ok := Countries[countryCode].Provinces[provinceCode] + return ok +} + +func CheckCity(countryCode, provinceCode, cityCode string) bool { + if CheckProvince(countryCode, provinceCode) == false { + return false + } + _, ok := Countries[countryCode].Provinces[provinceCode].Cities[cityCode] + return ok +} diff --git a/internal/regions/init.go b/internal/regions/init.go new file mode 100644 index 000000000..925c4a356 --- /dev/null +++ b/internal/regions/init.go @@ -0,0 +1,70 @@ +package regions + +import ( + "challenge16/utils" + "fmt" + "log" +) + +const ( + filePath = "cities.csv" +) + +type ( + countryData struct { + Name string + Provinces map[string]provinceData + } + + provinceData struct { + Name string + Cities map[string]string + } +) + +var Countries = make(map[string]countryData) + +func init() { + // Load data from CSV into the countries map + err := LoadDataIntoMap(filePath) + if err != nil { + log.Fatal(fmt.Errorf("error loading data into map: %w", err)) + } +} + +func LoadDataIntoMap(csvFilePath string) error { + // Load data from CSV into the countries map + + datas, err := utils.ParseCSV(csvFilePath) + if err != nil { + return err + } + + for _, data := range datas { + // Add data to the map + if _, ok := Countries[data.CountryCode]; !ok { + Countries[data.CountryCode] = countryData{ + Name: data.CountryName, + Provinces: map[string]provinceData{ + data.ProvinceCode: { + Name: data.ProvinceName, + Cities: map[string]string{ + data.CityCode: data.CityName, + }, + }, + }, + } + continue + } + if _, ok := Countries[data.CountryCode].Provinces[data.ProvinceCode]; !ok { + Countries[data.CountryCode].Provinces[data.ProvinceCode] = provinceData{ + Name: data.ProvinceName, + Cities: map[string]string{data.CityCode: data.CityName}, + } + continue + } + Countries[data.CountryCode].Provinces[data.ProvinceCode].Cities[data.CityCode] = data.CityName + } + + return nil +} diff --git a/internal/response/create_error.go b/internal/response/create_error.go new file mode 100644 index 000000000..355dc4150 --- /dev/null +++ b/internal/response/create_error.go @@ -0,0 +1,19 @@ +package response + +func CreateError(statusCode int, respcode string, err error) Response { + return Response{ + HttpStatusCode: statusCode, + Status: false, + ResponseCode: respcode, + Error: err, + } +} + +func CreateSuccess(statusCode int, respcode string, data interface{}) Response { + return Response{ + HttpStatusCode: statusCode, + Status: true, + ResponseCode: respcode, + Data: data, + } +} diff --git a/internal/response/handle_error.go b/internal/response/handle_error.go new file mode 100644 index 000000000..5a67ecef3 --- /dev/null +++ b/internal/response/handle_error.go @@ -0,0 +1,30 @@ +package response + +import ( + "regexp" + + "github.com/gofiber/fiber/v2" +) + +var ( + sqlRegexPattern = regexp.MustCompile(`SQLSTATE (\d{5})`) +) + +type custError struct { + Response + Error string `json:"error"` +} + +func (resp Response) WriteToJSON(c *fiber.Ctx) error { + if resp.Error == nil { + return c.Status(resp.HttpStatusCode).JSON(resp) + } + newCustError := custError{ + Response: resp, + } + if resp.Error != nil { + newCustError.Error = resp.Error.Error() + } + + return c.Status(resp.HttpStatusCode).JSON(newCustError) +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 000000000..d2adb8be4 --- /dev/null +++ b/internal/response/response.go @@ -0,0 +1,21 @@ +package response + +type Response struct { + HttpStatusCode int `json:"-"` + Status bool `json:"status"` + ResponseCode string `json:"resp_code"` + Error error `json:"-"` //will be marshalled to string when WriteToJSON is called + Data interface{} `json:"data,omitempty"` +} + +type ValidationErrorResponse struct { + Status bool `json:"status"` + ResponseCode string `json:"resp_code"` + Errors []InvalidField `json:"errors"` +} + +type InvalidField struct { + FailedField string `json:"field"` + Tag string `json:"tag"` + Value interface{} `json:"value"` +} diff --git a/utils/csv_parse.go b/utils/csv_parse.go new file mode 100644 index 000000000..108b6c842 --- /dev/null +++ b/utils/csv_parse.go @@ -0,0 +1,46 @@ +package utils + +import ( + "encoding/csv" + "os" +) + +type Data struct { + CityCode string + CityName string + ProvinceCode string + ProvinceName string + CountryCode string + CountryName string +} + +func ParseCSV(filename string) ([]Data, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + + var dataList []Data + for i, record := range records { + if i == 0 { + continue // Skip header + } + dataList = append(dataList, Data{ + CityCode: record[0], + ProvinceCode: record[1], + CountryCode: record[2], + CityName: record[3], + ProvinceName: record[4], + CountryName: record[5], + }) + } + + return dataList, nil +} diff --git a/validation/handle_request.go b/validation/handle_request.go new file mode 100644 index 000000000..67cffd002 --- /dev/null +++ b/validation/handle_request.go @@ -0,0 +1,62 @@ +package validation + +import ( + "challenge16/internal/response" + "fmt" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" +) + +const ( + bindingErrCode = "BINDING_ERROR" + validationErrCode = "VALIDATION_ERROR" + + fieldTag_JSON = "json" + fieldTag_Query = "query" +) + +/* +BindAndValidateRequest binds and validates the request. +Req should be a pointer to the request struct. +*/ +func BindAndValidateJSONRequest(c *fiber.Ctx, req interface{}) (bool, error) { + if err := c.BodyParser(req); err != nil { + return false, response.Response{ + HttpStatusCode: 400, + Status: false, + ResponseCode: bindingErrCode, + Error: fmt.Errorf("error parsing request:%w", err), + }.WriteToJSON(c) + } + + if ok, errResponse := validateRequestInDetailBasedOnFieldTag(c, req, fieldTag_JSON); !ok { + return false, errResponse + } + + log.Debug("req after validation:", req) //alter later if need to hide sensitive data + + return true, nil +} + +/* +BindAndValidateURLQueryRequest binds and validates the request in URL query format. +Req should be a pointer to the request struct. +*/ +func BindAndValidateURLQueryRequest(c *fiber.Ctx, req interface{}) (bool, error) { + if err := c.QueryParser(req); err != nil { + return false, response.Response{ + HttpStatusCode: 400, + Status: false, + ResponseCode: bindingErrCode, + Error: fmt.Errorf("error parsing request:%w", err), + }.WriteToJSON(c) + } + + if ok, errResponse := validateRequestInDetailBasedOnFieldTag(c, req, fieldTag_Query); !ok { + return false, errResponse + } + + log.Debug("req after validation:", req) //alter later if need to hide sensitive data + return true, nil +} diff --git a/validation/validation.go b/validation/validation.go new file mode 100644 index 000000000..031b2b4f7 --- /dev/null +++ b/validation/validation.go @@ -0,0 +1,74 @@ +package validation + +import ( + "challenge16/internal/response" + "fmt" + "net/http" + "reflect" + + "github.com/gofiber/fiber/v2/log" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +var validate = validator.New() + +func validateRequestInDetailBasedOnFieldTag(c *fiber.Ctx, req interface{}, tag string) (bool, error) { + + errorResponses := []response.InvalidField{} + errs := validate.Struct(req) + + if errs != nil { + + for _, err := range errs.(validator.ValidationErrors) { + // Get the required tag name using reflection + formFieldKey := getFieldKeyByStructTag(req, err.Field(), tag) + + e := response.InvalidField{ + FailedField: formFieldKey, + Tag: err.Tag(), + Value: err.Value(), + } + + // switch e.FailedField { + // case "password", "Password": + // log.Debug(fmt.Sprintf("[%s]: '%v' | Needs to implement '%s'", e.FailedField, "--hidden--", e.Tag)) + // default: + log.Debug(fmt.Sprintf("[%s]: '%v' | Needs to implement '%s'", e.FailedField, e.Value, e.Tag)) + // } + + errorResponses = append(errorResponses, e) + } + log.Debug("error validating request:", errorResponses) + return false, c.Status(http.StatusBadRequest).JSON(response.ValidationErrorResponse{ + Status: false, + ResponseCode: validationErrCode, + Errors: errorResponses, + }) + } + + return true, nil +} + +// Function to get the tag-name of a struct field based on the tag passed +func getFieldKeyByStructTag(req interface{}, fieldName string, tag string) string { + val := reflect.TypeOf(req) + + // Check if the value passed is a pointer and get the element type + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // Find the struct field by name and return the field's tag based key + field, found := val.FieldByName(fieldName) + if !found { + return fieldName // Return the field name if no such tag is found + } + + fieldKey := field.Tag.Get(tag) + if fieldKey == "" { + return fieldName // Return the field name itself if the given tag is not defined for the field + } + return fieldKey +} From c8d659dbdae39e520e0510b1e03b832dcba2b3d2 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Sun, 23 Feb 2025 10:35:33 +0530 Subject: [PATCH 02/15] Race prevention (sync.RWMutex) (#4) RW locks implemented on databank to ensure thread safety --- SUMMARY.md | 7 +++- internal/data/data.go | 89 ++++++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/SUMMARY.md b/SUMMARY.md index d69aa0dca..16be9b6ba 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -98,6 +98,8 @@ This project was developed as part of a machine task for a company interview pro - Region validation using CSV data - Distributor existence checks - Permission hierarchy validation +- **Concurrency Handling** + - Prevention of race conditions during concurrent access using read and write locks (sync.RWMutex) ### Key Components 1. **Route Handlers** (`internal/handler`) @@ -105,11 +107,12 @@ This project was developed as part of a machine task for a company interview pro - Input validation and sanitization - Error handling and response formatting -2. **Business Logic** +2. **Business Logic & Data Management** (`internal/data`) - Distributor management logic - Permission validation and inheritance - Region validation against CSV data - + - Thread-safe data operations using RWMutex + - Optimized read/write locking for better performance ## Technical Notes diff --git a/internal/data/data.go b/internal/data/data.go index eb8a7935d..1bab0dfcf 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -5,6 +5,7 @@ import ( "challenge16/internal/response" "errors" "strings" + "sync" ) const ( @@ -30,7 +31,10 @@ var ( ) type ( - DataBank map[string]distributorData + DataBank struct { + Distributors map[string]distributorData + mu sync.RWMutex + } distributorData struct { permissionDataGlobally @@ -53,15 +57,16 @@ type ( ) func NewDataBank() DataBank { - return make(DataBank) + return DataBank{ + Distributors: make(map[string]distributorData), + mu: sync.RWMutex{}, + } } func (db *DataBank) MarkInclusion(distributor, regionString string) response.Response { - return markAsIncluded(*db, distributor, regionString) -} - -func markAsIncluded(db DataBank, distributor, regionString string) response.Response { - if _, ok := db[distributor]; !ok { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; !ok { return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } @@ -71,41 +76,39 @@ func markAsIncluded(db DataBank, distributor, regionString string) response.Resp } if regionType == COUNTRY { - db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ PermissionType: allowAll, } return successResponse } - if _, ok := db[distributor].permissionDataGlobally[countryCode]; !ok { - db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode]; !ok { + db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ PermissionType: custom, Inclusions: make(map[string]permissionDataInProvince), } } if regionType == PROVINCE { - db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ PermissionType: allowAll, } return successResponse } - if _, ok := db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { - db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { + db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ PermissionType: custom, Inclusions: make(map[string]bool), } } - db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = true + db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = true return successResponse } func (db *DataBank) MarkExclusion(distributor, regionString string) response.Response { - return markAsExcluded(*db, distributor, regionString) -} - -func markAsExcluded(db DataBank, distributor, regionString string) response.Response { - if _, ok := db[distributor]; !ok { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; !ok { return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } @@ -115,37 +118,37 @@ func markAsExcluded(db DataBank, distributor, regionString string) response.Resp } if regionType == COUNTRY { - db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ PermissionType: denyAll, } return successResponse } - if _, ok := db[distributor].permissionDataGlobally[countryCode]; !ok { - db[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode]; !ok { + db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ PermissionType: custom, Inclusions: make(map[string]permissionDataInProvince), } } if regionType == PROVINCE { - db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ PermissionType: denyAll, } return successResponse } - if _, ok := db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { - db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { + db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ PermissionType: custom, Inclusions: make(map[string]bool), } } - db[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = false + db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = false return successResponse } -func (db DataBank) getRegionDetails(regionString string) (countryCode, provinceCode, cityCode, regionType string, err error) { +func (db *DataBank) getRegionDetails(regionString string) (countryCode, provinceCode, cityCode, regionType string, err error) { subStrings := strings.Split(regionString, "-") // Splitting the regionString by "-", this is the regionString I am assuming switch len(subStrings) { case 1: @@ -176,7 +179,7 @@ func (db DataBank) getRegionDetails(regionString string) (countryCode, provinceC return } -func (db DataBank) IsAllowed(distributor, regionString string) response.Response { +func (db *DataBank) IsAllowed(distributor, regionString string) response.Response { countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) if err != nil { return response.CreateError(404, REGION_NOT_FOUND, err) @@ -197,8 +200,10 @@ func (db DataBank) IsAllowed(distributor, regionString string) response.Response } } -func (db DataBank) isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType string) (bool, error) { - permissionData, ok := db[distributor] +func (db *DataBank) isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType string) (bool, error) { + db.mu.RLock() + defer db.mu.RUnlock() + permissionData, ok := db.Distributors[distributor] if !ok { return false, ErrDistributorNotFound } @@ -241,12 +246,14 @@ func (db DataBank) isAllowedForTheDistributor(distributor, countryCode, province return false, nil } -func (db DataBank) AddSubDistributor(subDistributor, parentDistributor string) response.Response { - if _, ok := db[parentDistributor]; !ok { +func (db *DataBank) AddSubDistributor(subDistributor, parentDistributor string) response.Response { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[parentDistributor]; !ok { return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } - if _, ok := db[subDistributor]; !ok { - db[subDistributor] = distributorData{ + if _, ok := db.Distributors[subDistributor]; !ok { + db.Distributors[subDistributor] = distributorData{ permissionDataGlobally: make(permissionDataGlobally), parentDistributor: &parentDistributor, } @@ -255,20 +262,24 @@ func (db DataBank) AddSubDistributor(subDistributor, parentDistributor string) r } func (db *DataBank) AddDistributor(distributor string) response.Response { - if _, ok := (*db)[distributor]; ok { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; ok { return response.CreateError(400, "DISTRIBUTOR_EXISTS", ErrDistributorExists) } - (*db)[distributor] = distributorData{ + db.Distributors[distributor] = distributorData{ permissionDataGlobally: make(permissionDataGlobally), parentDistributor: nil, } return createdResponse } -func (db DataBank) RemoveDistributor(distributor string) response.Response { - if _, ok := db[distributor]; !ok { +func (db *DataBank) RemoveDistributor(distributor string) response.Response { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; !ok { return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } - delete(db, distributor) + delete(db.Distributors, distributor) return successResponse } From e7260c5ff17f149552c48019e698a5ed993acc30 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 28 Feb 2025 12:35:24 +0530 Subject: [PATCH 03/15] Contract submission as text (#6) --- .gitignore | 4 +- cmd/main.go | 2 +- internal/data/data.go | 269 +++++++++++++++++--------------- internal/handler/distributer.go | 18 --- internal/handler/permission.go | 90 ++++++++++- internal/regions/check.go | 56 ++++++- 6 files changed, 282 insertions(+), 157 deletions(-) diff --git a/.gitignore b/.gitignore index 88d050b19..7cdcd1bf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -main \ No newline at end of file +main +.vscode +__debug_bin* \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 1a60572e1..615012962 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,13 +21,13 @@ func main() { { distributor.Post("/", handler.AddDistributor) distributor.Delete("/:distributor", handler.RemoveDistributor) - distributor.Post("/add-sub", handler.AddSubDistributor) } permission := app.Group("/permission") { permission.Get("/check", handler.CheckIfDistributionIsAllowed) permission.Post("/allow", handler.AllowDistribution) + permission.Post("/contract", handler.ApplyContract) permission.Post("/disallow", handler.DisallowDistribution) } } diff --git a/internal/data/data.go b/internal/data/data.go index 1bab0dfcf..879e69487 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -4,7 +4,7 @@ import ( "challenge16/internal/regions" "challenge16/internal/response" "errors" - "strings" + "fmt" "sync" ) @@ -32,16 +32,11 @@ var ( type ( DataBank struct { - Distributors map[string]distributorData + Distributors map[string]permissionDataGlobal mu sync.RWMutex } - distributorData struct { - permissionDataGlobally - parentDistributor *string - } - - permissionDataGlobally map[string]permissionDataInCountry + permissionDataGlobal map[string]permissionDataInCountry permissionDataInCountry struct { PermissionType string // "allow-all", "deny-all", "custom" @@ -58,51 +53,69 @@ type ( func NewDataBank() DataBank { return DataBank{ - Distributors: make(map[string]distributorData), + Distributors: make(map[string]permissionDataGlobal), mu: sync.RWMutex{}, } } func (db *DataBank) MarkInclusion(distributor, regionString string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() + db.mu.RLock() if _, ok := db.Distributors[distributor]; !ok { return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } + db.mu.RUnlock() - countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) + countryCode, provinceCode, cityCode, regionType, err := regions.GetRegionDetails(regionString) if err != nil { return response.CreateError(404, REGION_NOT_FOUND, err) } + db.markAsIncluded(distributor, countryCode, provinceCode, cityCode, regionType) + return successResponse +} + +func (db *DataBank) markAsIncluded(distributor, countryCode, provinceCode, cityCode, regionType string) { + db.mu.Lock() + defer db.mu.Unlock() + + if _, ok := db.Distributors[distributor]; !ok { + db.Distributors[distributor] = make(permissionDataGlobal) + } + if regionType == COUNTRY { - db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + db.Distributors[distributor][countryCode] = permissionDataInCountry{ PermissionType: allowAll, + Inclusions: make(map[string]permissionDataInProvince), + Exclusions: make(map[string]permissionDataInProvince), } - return successResponse + return } - if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode]; !ok { - db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + if _, ok := db.Distributors[distributor][countryCode]; !ok { + db.Distributors[distributor][countryCode] = permissionDataInCountry{ PermissionType: custom, Inclusions: make(map[string]permissionDataInProvince), + Exclusions: make(map[string]permissionDataInProvince), } } if regionType == PROVINCE { - db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + db.Distributors[distributor][countryCode].Inclusions[provinceCode] = permissionDataInProvince{ PermissionType: allowAll, + Inclusions: make(map[string]bool), + Exclusions: make(map[string]bool), } - return successResponse + return } - if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { - db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + if _, ok := db.Distributors[distributor][countryCode].Inclusions[provinceCode]; !ok { + db.Distributors[distributor][countryCode].Inclusions[provinceCode] = permissionDataInProvince{ PermissionType: custom, Inclusions: make(map[string]bool), + Exclusions: make(map[string]bool), } } - db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = true - return successResponse + db.Distributors[distributor][countryCode].Inclusions[provinceCode].Inclusions[cityCode] = true + return } func (db *DataBank) MarkExclusion(distributor, regionString string) response.Response { @@ -112,174 +125,170 @@ func (db *DataBank) MarkExclusion(distributor, regionString string) response.Res return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } - countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) + countryCode, provinceCode, cityCode, regionType, err := regions.GetRegionDetails(regionString) if err != nil { return response.CreateError(404, REGION_NOT_FOUND, err) } + db.markAsExcluded(distributor, countryCode, provinceCode, cityCode, regionType) + return successResponse +} + +func (db *DataBank) markAsExcluded(distributor, countryCode, provinceCode, cityCode, regionType string) { + db.mu.Lock() + defer db.mu.Unlock() + if regionType == COUNTRY { - db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + db.Distributors[distributor][countryCode] = permissionDataInCountry{ PermissionType: denyAll, + Inclusions: make(map[string]permissionDataInProvince), + Exclusions: make(map[string]permissionDataInProvince), } - return successResponse + return } - if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode]; !ok { - db.Distributors[distributor].permissionDataGlobally[countryCode] = permissionDataInCountry{ + if _, ok := db.Distributors[distributor][countryCode]; !ok { + db.Distributors[distributor][countryCode] = permissionDataInCountry{ PermissionType: custom, Inclusions: make(map[string]permissionDataInProvince), + Exclusions: make(map[string]permissionDataInProvince), } } if regionType == PROVINCE { - db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + + db.Distributors[distributor][countryCode].Exclusions[provinceCode] = permissionDataInProvince{ PermissionType: denyAll, + Inclusions: make(map[string]bool), + Exclusions: make(map[string]bool), } - return successResponse + return } - if _, ok := db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode]; !ok { - db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode] = permissionDataInProvince{ + if _, ok := db.Distributors[distributor][countryCode].Exclusions[provinceCode]; !ok { + db.Distributors[distributor][countryCode].Exclusions[provinceCode] = permissionDataInProvince{ PermissionType: custom, Inclusions: make(map[string]bool), + Exclusions: make(map[string]bool), } } - db.Distributors[distributor].permissionDataGlobally[countryCode].Inclusions[provinceCode].Inclusions[cityCode] = false + db.Distributors[distributor][countryCode].Exclusions[provinceCode].Exclusions[cityCode] = false + return +} + +func (db *DataBank) AddDistributor(distributor string) response.Response { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; ok { + return response.CreateError(400, "DISTRIBUTOR_EXISTS", ErrDistributorExists) + } + db.Distributors[distributor] = make(permissionDataGlobal) + return createdResponse +} +func (db *DataBank) RemoveDistributor(distributor string) response.Response { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + } + delete(db.Distributors, distributor) return successResponse } -func (db *DataBank) getRegionDetails(regionString string) (countryCode, provinceCode, cityCode, regionType string, err error) { - subStrings := strings.Split(regionString, "-") // Splitting the regionString by "-", this is the regionString I am assuming - switch len(subStrings) { - case 1: - countryCode = subStrings[0] - if !regions.CheckCountry(countryCode) { - err = errors.New("country not found") - return +func (db *DataBank) ApplyContract(distributorHeirarchy, includeRegions, excludeRegions []string) response.Response { + if len(distributorHeirarchy) > 1 { + //ensure that they have required permission + for i := 1; i < len(distributorHeirarchy); i++ { //skip the first distributor as it may be a new distributor + if _, ok := db.Distributors[distributorHeirarchy[i]]; !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("parent distributor %s not found", distributorHeirarchy[i])) + } } - regionType = COUNTRY - case 2: - countryCode = subStrings[1] - provinceCode = subStrings[0] - regionType = PROVINCE - if !regions.CheckProvince(countryCode, provinceCode) { - err = errors.New("country/province not found") - return + + for _, region := range includeRegions { + countryCode, provinceCode, cityCode, regionType, err := regions.GetRegionDetails(region) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } + + //check if the region is allowed for the immediate parent distributor + isAllowedForImmediateParent := db.isAllowedForTheDistributor(distributorHeirarchy[1], countryCode, provinceCode, cityCode, regionType) + if !isAllowedForImmediateParent { + return response.CreateError(200, "DISTRIBUTION_NOT_ALLOWED", fmt.Errorf("distribution not allowed for the immediate parent(%s) in region %s which is mentioned in 'INCLUDE'", distributorHeirarchy[1], region)) + } } - default: - countryCode = subStrings[2] - provinceCode = subStrings[1] - cityCode = subStrings[0] - regionType = CITY - if !regions.CheckCity(countryCode, provinceCode, cityCode) { - err = errors.New("country/province/city not found") - return + } + + //validate the regions mentioned in the contract + for _, region := range includeRegions { + _, _, _, _, err := regions.GetRegionDetails(region) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) } } - return -} -func (db *DataBank) IsAllowed(distributor, regionString string) response.Response { - countryCode, provinceCode, cityCode, regionType, err := db.getRegionDetails(regionString) - if err != nil { - return response.CreateError(404, REGION_NOT_FOUND, err) + for _, region := range excludeRegions { + _, _, _, _, err := regions.GetRegionDetails(region) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } } - isAllowed, err := db.isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType) - if err != nil { - if err == ErrDistributorNotFound { - return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, err) + + if _, ok := db.Distributors[distributorHeirarchy[0]]; !ok { + db.Distributors[distributorHeirarchy[0]] = make(permissionDataGlobal) + } + + //apply the contract + for _, includeRegion := range includeRegions { + countryCode, provinceCode, cityCode, regionType, _ := regions.GetRegionDetails(includeRegion) + db.markAsIncluded(distributorHeirarchy[0], countryCode, provinceCode, cityCode, regionType) + } + + for _, excludeRegion := range excludeRegions { + countryCode, provinceCode, cityCode, regionType, _ := regions.GetRegionDetails(excludeRegion) + if !db.isAllowedForTheDistributor(distributorHeirarchy[0], countryCode, provinceCode, cityCode, regionType) { + continue //if the region is already in allow list(possibly by other contracts), then no need to exclude it } else { - return response.CreateError(500, INTERNAL_SERVER_ERROR, err) + db.markAsExcluded(distributorHeirarchy[0], countryCode, provinceCode, cityCode, regionType) } } - if !isAllowed { - return response.CreateError(200, "DISTRIBUTION_NOT_ALLOWED", nil) - } else { - return response.CreateSuccess(200, "DISTRIBUTION_ALLOWED", nil) - } + return successResponse } -func (db *DataBank) isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType string) (bool, error) { +func (db *DataBank) isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType string) bool { db.mu.RLock() defer db.mu.RUnlock() - permissionData, ok := db.Distributors[distributor] + permissionDataGlobally, ok := db.Distributors[distributor] if !ok { - return false, ErrDistributorNotFound - } - - if permissionData.parentDistributor != nil { - if allowed, err := db.isAllowedForTheDistributor(*permissionData.parentDistributor, countryCode, provinceCode, cityCode, regionType); err != nil { - return false, err - } else if !allowed { - return false, nil - } + return false } - if permissionDataInCountry, exists := permissionData.permissionDataGlobally[countryCode]; exists { + if permissionDataInCountry, exists := permissionDataGlobally[countryCode]; exists { switch permissionDataInCountry.PermissionType { case allowAll: - return true, nil + return true case denyAll: - return false, nil + return false default: if regionType == COUNTRY { - return false, nil + return false } } if permissionDataInProvince, exists := permissionDataInCountry.Inclusions[provinceCode]; exists { switch permissionDataInProvince.PermissionType { case allowAll: - return true, nil + return true case denyAll: - return false, nil + return false default: if regionType == PROVINCE { - return false, nil + return false } if permissionDataInProvince.Inclusions[cityCode] { - return true, nil + return true } } } } - return false, nil -} - -func (db *DataBank) AddSubDistributor(subDistributor, parentDistributor string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() - if _, ok := db.Distributors[parentDistributor]; !ok { - return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) - } - if _, ok := db.Distributors[subDistributor]; !ok { - db.Distributors[subDistributor] = distributorData{ - permissionDataGlobally: make(permissionDataGlobally), - parentDistributor: &parentDistributor, - } - } - return createdResponse -} - -func (db *DataBank) AddDistributor(distributor string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() - if _, ok := db.Distributors[distributor]; ok { - return response.CreateError(400, "DISTRIBUTOR_EXISTS", ErrDistributorExists) - } - db.Distributors[distributor] = distributorData{ - permissionDataGlobally: make(permissionDataGlobally), - parentDistributor: nil, - } - return createdResponse -} - -func (db *DataBank) RemoveDistributor(distributor string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() - if _, ok := db.Distributors[distributor]; !ok { - return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) - } - delete(db.Distributors, distributor) - return successResponse + return false } diff --git a/internal/handler/distributer.go b/internal/handler/distributer.go index 18029b5f5..7553b3fd2 100644 --- a/internal/handler/distributer.go +++ b/internal/handler/distributer.go @@ -21,24 +21,6 @@ func (h *handler) AddDistributor(c *fiber.Ctx) error { return resp.WriteToJSON(c) } -func (h *handler) AddSubDistributor(c *fiber.Ctx) error { - req := new(struct { - SubDistributor string `json:"sub_distributor" validate:"required"` - ParentDistributor string `json:"parent_distributor" validate:"required"` - }) - - if ok, err := validation.BindAndValidateJSONRequest(c, req); !ok { - return err - } - - resp := h.databank.AddSubDistributor(req.SubDistributor, req.ParentDistributor) - return resp.WriteToJSON(c) -} - -type Ss struct { - Distributor string `param:"distributor" validate:"required"` -} - func (h *handler) RemoveDistributor(c *fiber.Ctx) error { distributor := c.Params("distributor") if distributor == "" { diff --git a/internal/handler/permission.go b/internal/handler/permission.go index 6cc098a9f..212fa9c71 100644 --- a/internal/handler/permission.go +++ b/internal/handler/permission.go @@ -1,7 +1,10 @@ package handler import ( + "challenge16/internal/response" "challenge16/validation" + "errors" + "strings" "github.com/gofiber/fiber/v2" ) @@ -18,8 +21,8 @@ func (h *handler) CheckIfDistributionIsAllowed(c *fiber.Ctx) error { return err } - resp := h.databank.IsAllowed(req.Distributor, req.RegionString) - return resp.WriteToJSON(c) + // return resp.WriteToJSON(c) + return nil } func (h *handler) AllowDistribution(c *fiber.Ctx) error { @@ -49,3 +52,86 @@ func (h *handler) DisallowDistribution(c *fiber.Ctx) error { resp := h.databank.MarkExclusion(req.Distributor, req.RegionString) return resp.WriteToJSON(c) } + +func (h *handler) ApplyContract(c *fiber.Ctx) error { + contract := string(c.Body()) + distributorHeirarchy, includeRegions, excludeRegions, err := getContractData(contract) + if err != nil { + return response.Response{ + HttpStatusCode: 400, + ResponseCode: "INVALID_CONTRACT", + Error: err, + }.WriteToJSON(c) + } + resp := h.databank.ApplyContract(distributorHeirarchy, includeRegions, excludeRegions) + return resp.WriteToJSON(c) +} + +func getContractData(contract string) (distributorHeirarchy, includeRegions, excludeRegions []string, err error) { + //Example contract: + /* + Permissions for DISTRIBUTOR1 + INCLUDE: IN + INCLUDE: UN + EXCLUDE: KA-IN + EXCLUDE: CENAI-TN-IN + */ + + //or + + /* + Permissions for DISTRIBUTOR1 < DISTRIBUTOR2 < DISTRIBUTOR3 + INCLUDE: YADGR-KA-IN + */ + + contractData := strings.Split(contract, "\n") + + if len(contractData) < 2 { + err = errors.New("Invalid contract, regions not found") + return + } + heading := strings.TrimLeft(contractData[0], " ") + if !strings.HasPrefix(heading, "Permissions for ") { + err = errors.New("Invalid contract, heading line: Prefix: 'Permissions for ' not found") + return + } else { + data := strings.TrimPrefix(heading, "Permissions for ") + data = strings.TrimRight(data, " ") //to avoid spaces at the end made by mistake + distributorHeirarchy = strings.Split(data, " < ") + if len(distributorHeirarchy) == 0 { + err = errors.New("Invalid contract, distributor(s) not found in heading line after 'Permissions for': " + data) + return + } else if len(distributorHeirarchy) == 1 { + if distributorHeirarchy[0] == "" { + err = errors.New("Invalid contract, distributor(s) not found in heading line after 'Permissions for': " + data) + return + } + } + } + + for _, data := range contractData[1:] { + data = strings.TrimLeft(data, " ") + switch { + case strings.HasPrefix(data, "INCLUDE: "): + data = strings.TrimPrefix(data, "INCLUDE: ") + includeRegions = append(includeRegions, data) + case strings.HasPrefix(data, "EXCLUDE: "): + data = strings.TrimPrefix(data, "EXCLUDE: ") + excludeRegions = append(excludeRegions, data) + default: + err = errors.New("Invalid contract, invalid line found: " + data) + } + } + + if len(distributorHeirarchy) == 0 { + err = errors.New("Invalid contract, distributor(s) not found") + return + } + + if len(includeRegions) == 0 && len(excludeRegions) == 0 { + err = errors.New("Invalid contract, no permissions found") + return + } + + return +} diff --git a/internal/regions/check.go b/internal/regions/check.go index d63bff8a0..95141821d 100644 --- a/internal/regions/check.go +++ b/internal/regions/check.go @@ -1,22 +1,68 @@ package regions -func CheckCountry(countryCode string) bool { +import ( + "errors" + "strings" +) + +const ( + allowAll = "allow-all" + denyAll = "deny-all" + custom = "custom" + + COUNTRY = "country" + PROVINCE = "province" + CITY = "city" +) + +func checkCountry(countryCode string) bool { _, ok := Countries[countryCode] return ok } -func CheckProvince(countryCode, provinceCode string) bool { - if CheckCountry(countryCode) == false { +func checkProvince(countryCode, provinceCode string) bool { + if checkCountry(countryCode) == false { return false } _, ok := Countries[countryCode].Provinces[provinceCode] return ok } -func CheckCity(countryCode, provinceCode, cityCode string) bool { - if CheckProvince(countryCode, provinceCode) == false { +func checkCity(countryCode, provinceCode, cityCode string) bool { + if checkProvince(countryCode, provinceCode) == false { return false } _, ok := Countries[countryCode].Provinces[provinceCode].Cities[cityCode] return ok } + +func GetRegionDetails(regionString string) (countryCode, provinceCode, cityCode, regionType string, err error) { + subStrings := strings.Split(regionString, "-") // Splitting the regionString by "-", this is the regionString I am assuming + switch len(subStrings) { + case 1: + countryCode = subStrings[0] + if !checkCountry(countryCode) { + err = errors.New("country not found: " + countryCode) + return + } + regionType = COUNTRY + case 2: + countryCode = subStrings[1] + provinceCode = subStrings[0] + regionType = PROVINCE + if !checkProvince(countryCode, provinceCode) { + err = errors.New("country/province not found: " + countryCode + "-" + provinceCode) + return + } + default: + countryCode = subStrings[2] + provinceCode = subStrings[1] + cityCode = subStrings[0] + regionType = CITY + if !checkCity(countryCode, provinceCode, cityCode) { + err = errors.New("country/province/city not found: " + countryCode + "-" + provinceCode + "-" + cityCode) + return + } + } + return +} From e072498baed78096c94580affae62c01a579f472 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 28 Feb 2025 14:51:32 +0530 Subject: [PATCH 04/15] Get regions' list endpoints (#7) --- cmd/main.go | 7 ++++ internal/handler/permission.go | 4 +-- internal/handler/regions.go | 33 ++++++++++++++++++ internal/regions/get.go | 62 ++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 internal/handler/regions.go create mode 100644 internal/regions/get.go diff --git a/cmd/main.go b/cmd/main.go index 615012962..dc7d739f8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,13 @@ func main() { permission.Post("/contract", handler.ApplyContract) permission.Post("/disallow", handler.DisallowDistribution) } + + regions := app.Group("/regions") + { + regions.Get("/countries", handler.GetCountries) + regions.Get("/provinces/:countryCode", handler.GetProvincesInCountry) + regions.Get("/cities/:countryCode/:provinceCode", handler.GetCitiesInProvince) + } } err := app.Listen(fmt.Sprintf(":4010")) diff --git a/internal/handler/permission.go b/internal/handler/permission.go index 212fa9c71..05ef307d8 100644 --- a/internal/handler/permission.go +++ b/internal/handler/permission.go @@ -9,13 +9,13 @@ import ( "github.com/gofiber/fiber/v2" ) -type SS struct { +type checkPermissionRequest struct { Distributor string `query:"distributor" validate:"required"` RegionString string `query:"region" validate:"required"` } func (h *handler) CheckIfDistributionIsAllowed(c *fiber.Ctx) error { - req := new(SS) + req := new(checkPermissionRequest) if ok, err := validation.BindAndValidateURLQueryRequest(c, req); !ok { return err diff --git a/internal/handler/regions.go b/internal/handler/regions.go new file mode 100644 index 000000000..5661f3d69 --- /dev/null +++ b/internal/handler/regions.go @@ -0,0 +1,33 @@ +package handler + +import ( + "challenge16/internal/regions" + "challenge16/internal/response" + "fmt" + + "github.com/gofiber/fiber/v2" +) + +func (h *handler) GetCountries(c *fiber.Ctx) error { + resp := regions.GetCountries() + return resp.WriteToJSON(c) +} + +func (h *handler) GetProvincesInCountry(c *fiber.Ctx) error { + countryCode := c.Params("countryCode") + if countryCode == "" { + return response.CreateError(400, URL_PARAM_MISSING, fmt.Errorf("Country code is required")).WriteToJSON(c) + } + resp := regions.GetProvincesInCountry(countryCode) + return resp.WriteToJSON(c) +} + +func (h *handler) GetCitiesInProvince(c *fiber.Ctx) error { + countryCode := c.Params("countryCode") + provinceCode := c.Params("provinceCode") + if countryCode == "" || provinceCode == "" { + return response.CreateError(400, URL_PARAM_MISSING, fmt.Errorf("Country code and province code are required")).WriteToJSON(c) + } + resp := regions.GetCitiesInProvince(countryCode, provinceCode) + return resp.WriteToJSON(c) +} diff --git a/internal/regions/get.go b/internal/regions/get.go new file mode 100644 index 000000000..f6a275aa4 --- /dev/null +++ b/internal/regions/get.go @@ -0,0 +1,62 @@ +package regions + +import ( + "challenge16/internal/response" + "fmt" +) + +type regionInfo struct { + Name string `json:"name"` + Code string `json:"code"` +} + +func GetCountries() response.Response { + countries := make([]regionInfo, 0, len(Countries)) + for code, country := range Countries { + countries = append(countries, regionInfo{ + Name: country.Name, + Code: code, + }) + } + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "countries": countries, + }) +} + +func GetProvincesInCountry(countryCode string) response.Response { + exists := checkCountry(countryCode) + if !exists { + return response.CreateError(404, "COUNTRY_NOT_FOUND", fmt.Errorf("Country not found: %s", countryCode)) + } + + country := Countries[countryCode] + provinces := make([]regionInfo, 0, len(country.Provinces)) + for code, province := range country.Provinces { + provinces = append(provinces, regionInfo{ + Name: province.Name, + Code: code, + }) + } + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "provinces": provinces, + }) +} + +func GetCitiesInProvince(countryCode, provinceCode string) response.Response { + exists := checkProvince(countryCode, provinceCode) + if !exists { + return response.CreateError(404, "PROVINCE_NOT_FOUND", fmt.Errorf("Province not found: %s-%s", countryCode, provinceCode)) + } + + province := Countries[countryCode].Provinces[provinceCode] + cities := make([]regionInfo, 0, len(province.Cities)) + for code, name := range province.Cities { + cities = append(cities, regionInfo{ + Name: name, + Code: code, + }) + } + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "cities": cities, + }) +} From b9a41b6c60be83a1035560222ec1bbd9880d577b Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 28 Feb 2025 15:07:49 +0530 Subject: [PATCH 05/15] Get distributors' list endpoints (#9) --- cmd/main.go | 1 + internal/data/data.go | 12 ++++++++++++ internal/handler/distributer.go | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index dc7d739f8..306850804 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,6 +21,7 @@ func main() { { distributor.Post("/", handler.AddDistributor) distributor.Delete("/:distributor", handler.RemoveDistributor) + distributor.Get("/", handler.GetDistributers) } permission := app.Group("/permission") diff --git a/internal/data/data.go b/internal/data/data.go index 879e69487..48a0d321a 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -292,3 +292,15 @@ func (db *DataBank) isAllowedForTheDistributor(distributor, countryCode, provinc } return false } + +func (db *DataBank) GetDistributors() response.Response { + db.mu.RLock() + defer db.mu.RUnlock() + distributors := make([]string, 0, len(db.Distributors)) + for distributor := range db.Distributors { + distributors = append(distributors, distributor) + } + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "distributors": distributors, + }) +} \ No newline at end of file diff --git a/internal/handler/distributer.go b/internal/handler/distributer.go index 7553b3fd2..951e94346 100644 --- a/internal/handler/distributer.go +++ b/internal/handler/distributer.go @@ -30,3 +30,8 @@ func (h *handler) RemoveDistributor(c *fiber.Ctx) error { resp := h.databank.RemoveDistributor(distributor) return resp.WriteToJSON(c) } + +func (h *handler) GetDistributers(c *fiber.Ctx) error { + resp := h.databank.GetDistributors() + return resp.WriteToJSON(c) +} \ No newline at end of file From cdf2d95376238c5b7e765db3a66b01890c0e5276 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Thu, 6 Mar 2025 15:57:06 +0530 Subject: [PATCH 06/15] Contract validations, Permission over-writing.. (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Get permissions of a distributor as text or in json format * Validate contract by its structure * Fumigate contract based on parentโ€™s existing permissions * Applying contract upon existing permissions * Proper handling of locking and unlocking (read and write locks used judiciously ) --- SUMMARY.md | 8 +- cmd/main.go | 3 +- internal/data/data.go | 620 +++++++++++++++++++++--------- internal/data/process_contract.go | 478 +++++++++++++++++++++++ internal/data/types.go | 69 ++++ internal/dto/dto.go | 84 ++++ internal/handler/distributer.go | 4 +- internal/handler/permission.go | 108 ++++-- internal/handler/regions.go | 32 +- internal/regions/check.go | 44 ++- internal/regions/get.go | 38 +- internal/response/create_error.go | 13 + 12 files changed, 1239 insertions(+), 262 deletions(-) create mode 100644 internal/data/process_contract.go create mode 100644 internal/data/types.go create mode 100644 internal/dto/dto.go diff --git a/SUMMARY.md b/SUMMARY.md index 16be9b6ba..6204de193 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -29,7 +29,7 @@ This project was developed as part of a machine task for a company interview pro - **Request Body**: ```json { - "distributor":"distributer_name" + "distributor":"distributor_name" } ``` - **Success Response**: 201 Created @@ -46,8 +46,8 @@ This project was developed as part of a machine task for a company interview pro - **Request Body**: ```json { - "parent_distributor":"distributer_name", - "sub_distributor":"new_sub_distributer_name"" + "parent_distributor":"distributor_name", + "sub_distributor":"new_sub_distributor_name"" } ``` - **Success Response**: 201 Created @@ -117,7 +117,7 @@ This project was developed as part of a machine task for a company interview pro ## Technical Notes - The system performs validation against a predefined list of cities/regions from cities.csv -- Distributor authentication is simplified (no session management) with distributer name passed in request body +- Distributor authentication is simplified (no session management) with distributor name passed in request body - All operations include validation for distributor existence and permission checks - The system maintains hierarchical relationships between distributors and sub-distributors diff --git a/cmd/main.go b/cmd/main.go index 306850804..c03728ce6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,7 +21,7 @@ func main() { { distributor.Post("/", handler.AddDistributor) distributor.Delete("/:distributor", handler.RemoveDistributor) - distributor.Get("/", handler.GetDistributers) + distributor.Get("/", handler.GetDistributors) } permission := app.Group("/permission") @@ -30,6 +30,7 @@ func main() { permission.Post("/allow", handler.AllowDistribution) permission.Post("/contract", handler.ApplyContract) permission.Post("/disallow", handler.DisallowDistribution) + permission.Get("/:distributor", handler.GetDistributorPermissions) } regions := app.Group("/regions") diff --git a/internal/data/data.go b/internal/data/data.go index 48a0d321a..43d5566b7 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -5,6 +5,7 @@ import ( "challenge16/internal/response" "errors" "fmt" + "strings" "sync" ) @@ -32,275 +33,538 @@ var ( type ( DataBank struct { - Distributors map[string]permissionDataGlobal + Distributors map[string]permissionData mu sync.RWMutex } - - permissionDataGlobal map[string]permissionDataInCountry - - permissionDataInCountry struct { - PermissionType string // "allow-all", "deny-all", "custom" - Inclusions map[string]permissionDataInProvince - Exclusions map[string]permissionDataInProvince - } - - permissionDataInProvince struct { - PermissionType string // "allow-all", "deny-all", "custom" - Inclusions map[string]bool - Exclusions map[string]bool - } ) func NewDataBank() DataBank { return DataBank{ - Distributors: make(map[string]permissionDataGlobal), + Distributors: make(map[string]permissionData), mu: sync.RWMutex{}, } } +func newPermissionData() permissionData { + return permissionData{ + includedCountries: make(map[string]bool), + includedProvinces: make(map[string]map[string]bool), + excludedProvinces: make(map[string]map[string]bool), + includedCities: make(map[string]map[string]map[string]bool), + excludedCities: make(map[string]map[string]map[string]bool), + } +} + func (db *DataBank) MarkInclusion(distributor, regionString string) response.Response { - db.mu.RLock() - if _, ok := db.Distributors[distributor]; !ok { - return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + if !db.distributorExists(distributor) { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) } - db.mu.RUnlock() - countryCode, provinceCode, cityCode, regionType, err := regions.GetRegionDetails(regionString) + region, err := regions.GetRegionDetails(regionString) if err != nil { return response.CreateError(404, REGION_NOT_FOUND, err) } - db.markAsIncluded(distributor, countryCode, provinceCode, cityCode, regionType) + db.markAsIncluded(distributor, region) return successResponse } -func (db *DataBank) markAsIncluded(distributor, countryCode, provinceCode, cityCode, regionType string) { +func (db *DataBank) markAsIncluded(distributor string, region regions.Region) { + db.createDistributorIfNotExists(distributor) + + countryCode, provinceCode, cityCode := region.CountryCode, region.ProvinceCode, region.CityCode + db.mu.Lock() defer db.mu.Unlock() - if _, ok := db.Distributors[distributor]; !ok { - db.Distributors[distributor] = make(permissionDataGlobal) - } + switch region.Type { + case COUNTRY: + db.Distributors[distributor].includedCountries[countryCode] = true + delete(db.Distributors[distributor].includedProvinces, countryCode) + delete(db.Distributors[distributor].excludedProvinces, countryCode) + delete(db.Distributors[distributor].includedCities, countryCode) + delete(db.Distributors[distributor].excludedCities, countryCode) + case PROVINCE: + if db.Distributors[distributor].includedCountries[countryCode] { + if _, exists := db.Distributors[distributor].excludedProvinces[countryCode]; exists { + if db.Distributors[distributor].excludedProvinces[countryCode][provinceCode] { + delete(db.Distributors[distributor].excludedProvinces[countryCode], provinceCode) + + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + delete(db.Distributors[distributor].includedCities[countryCode], provinceCode) + } + } + } + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode], provinceCode) + } + //no need to check in includedProvinces, because country is already included. so there will be only exceptions + } else { + //no need to check in excludedProvinces, because country is not included to have exceptions + if _, exists := db.Distributors[distributor].includedProvinces[countryCode]; exists { + if db.Distributors[distributor].includedProvinces[countryCode][provinceCode] { + + //excudedCities are no longer excluded as the province is now included + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode], provinceCode) + } + } else { + db.Distributors[distributor].includedProvinces[countryCode][provinceCode] = true + + //deleting includedCities in this province as the province itself is now included as a whole + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + delete(db.Distributors[distributor].includedCities[countryCode], provinceCode) + } + } + } else { + db.Distributors[distributor].includedProvinces[countryCode] = map[string]bool{provinceCode: true} - if regionType == COUNTRY { - db.Distributors[distributor][countryCode] = permissionDataInCountry{ - PermissionType: allowAll, - Inclusions: make(map[string]permissionDataInProvince), - Exclusions: make(map[string]permissionDataInProvince), - } - return - } - if _, ok := db.Distributors[distributor][countryCode]; !ok { - db.Distributors[distributor][countryCode] = permissionDataInCountry{ - PermissionType: custom, - Inclusions: make(map[string]permissionDataInProvince), - Exclusions: make(map[string]permissionDataInProvince), - } - } - if regionType == PROVINCE { - db.Distributors[distributor][countryCode].Inclusions[provinceCode] = permissionDataInProvince{ - PermissionType: allowAll, - Inclusions: make(map[string]bool), - Exclusions: make(map[string]bool), + //deleting includedCities in this province as the province itself is now included as a whole + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + delete(db.Distributors[distributor].includedCities[countryCode], provinceCode) + } + //as country or province were not there as 'included', so no need to check in excludedCities either + } } - return - } + case CITY: + if db.Distributors[distributor].includedCountries[countryCode] { + //there is no need to check in includedProvinces, because country is already included. so there will be only exceptions + + //checking in excludedProvinces: + if _, exists := db.Distributors[distributor].excludedProvinces[countryCode]; exists { + if db.Distributors[distributor].excludedProvinces[countryCode][provinceCode] { + + // if the province is excluded, then the city should be made to be included + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].includedCities[countryCode][provinceCode]; !exists { + db.Distributors[distributor].includedCities[countryCode][provinceCode] = map[string]bool{cityCode: true} + } + } else { + db.Distributors[distributor].includedCities[countryCode] = map[string]map[string]bool{provinceCode: {cityCode: true}} + } + } + } - if _, ok := db.Distributors[distributor][countryCode].Inclusions[provinceCode]; !ok { - db.Distributors[distributor][countryCode].Inclusions[provinceCode] = permissionDataInProvince{ - PermissionType: custom, - Inclusions: make(map[string]bool), - Exclusions: make(map[string]bool), + //checking in excludedCities: + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].excludedCities[countryCode][provinceCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode][provinceCode], cityCode) + } + } + } else { + //as not even the country is included, we just have to check in includedProvinces+excludedCities and includedCities + + //checking in includedProvinces: + if _, exists := db.Distributors[distributor].includedProvinces[countryCode]; exists { + if db.Distributors[distributor].includedProvinces[countryCode][provinceCode] { + + //ensure that city is not excluded + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].excludedCities[countryCode][provinceCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode][provinceCode], cityCode) + } + } + + } else { + //if the province is not included, then the city should be included + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].includedCities[countryCode][provinceCode]; !exists { + db.Distributors[distributor].includedCities[countryCode][provinceCode] = map[string]bool{cityCode: true} + } else { + db.Distributors[distributor].includedCities[countryCode][provinceCode][cityCode] = true + } + } else { + db.Distributors[distributor].includedCities[countryCode] = map[string]map[string]bool{provinceCode: {cityCode: true}} + } + } + } else { + + //if the province is not included along with country not being included, then the city should be included + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].includedCities[countryCode][provinceCode]; !exists { + db.Distributors[distributor].includedCities[countryCode][provinceCode] = map[string]bool{cityCode: true} + } else { + db.Distributors[distributor].includedCities[countryCode][provinceCode][cityCode] = true + } + } else { + db.Distributors[distributor].includedCities[countryCode] = map[string]map[string]bool{provinceCode: {cityCode: true}} + } + + //as country or province were not there as 'included', so need to check in excludedCities + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].excludedCities[countryCode][provinceCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode][provinceCode], cityCode) + } + } + + } } } - - db.Distributors[distributor][countryCode].Inclusions[provinceCode].Inclusions[cityCode] = true - return } func (db *DataBank) MarkExclusion(distributor, regionString string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() - if _, ok := db.Distributors[distributor]; !ok { - return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) + if !db.distributorExists(distributor) { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) } - countryCode, provinceCode, cityCode, regionType, err := regions.GetRegionDetails(regionString) + region, err := regions.GetRegionDetails(regionString) if err != nil { return response.CreateError(404, REGION_NOT_FOUND, err) } - db.markAsExcluded(distributor, countryCode, provinceCode, cityCode, regionType) + db.markAsExcluded(distributor, region) return successResponse } -func (db *DataBank) markAsExcluded(distributor, countryCode, provinceCode, cityCode, regionType string) { +func (db *DataBank) markAsExcluded(distributor string, region regions.Region) { + db.createDistributorIfNotExists(distributor) + + countryCode, provinceCode, cityCode := region.CountryCode, region.ProvinceCode, region.CityCode + db.mu.Lock() defer db.mu.Unlock() - if regionType == COUNTRY { - db.Distributors[distributor][countryCode] = permissionDataInCountry{ - PermissionType: denyAll, - Inclusions: make(map[string]permissionDataInProvince), - Exclusions: make(map[string]permissionDataInProvince), - } - return - } - if _, ok := db.Distributors[distributor][countryCode]; !ok { - db.Distributors[distributor][countryCode] = permissionDataInCountry{ - PermissionType: custom, - Inclusions: make(map[string]permissionDataInProvince), - Exclusions: make(map[string]permissionDataInProvince), - } - } - if regionType == PROVINCE { + switch region.Type { + case COUNTRY: + delete(db.Distributors[distributor].includedCountries, countryCode) + delete(db.Distributors[distributor].includedProvinces, countryCode) + delete(db.Distributors[distributor].includedCities, countryCode) + delete(db.Distributors[distributor].excludedProvinces, countryCode) + delete(db.Distributors[distributor].excludedCities, countryCode) + + case PROVINCE: + if db.Distributors[distributor].includedCountries[countryCode] { + //if the country is included, then the province should be excluded + if _, exists := db.Distributors[distributor].excludedProvinces[countryCode]; exists { + db.Distributors[distributor].excludedProvinces[countryCode][provinceCode] = true + } else { + db.Distributors[distributor].excludedProvinces[countryCode] = map[string]bool{provinceCode: true} + } + + //as the province as a whole is excluded, then the cities in the province need not be in excluded list + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode], provinceCode) + } + } else { + //if the country is not included, then the province should not be in included list + if _, exists := db.Distributors[distributor].includedProvinces[countryCode]; exists { + if db.Distributors[distributor].includedProvinces[countryCode][provinceCode] { + delete(db.Distributors[distributor].includedProvinces[countryCode], provinceCode) + + //as the province is excluded, then the cities in the province need not be in excluded list + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + delete(db.Distributors[distributor].excludedCities[countryCode], provinceCode) + } + } + } + + //ensure that no city in the province is in included list + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + delete(db.Distributors[distributor].includedCities[countryCode], provinceCode) + } - db.Distributors[distributor][countryCode].Exclusions[provinceCode] = permissionDataInProvince{ - PermissionType: denyAll, - Inclusions: make(map[string]bool), - Exclusions: make(map[string]bool), } - return - } - if _, ok := db.Distributors[distributor][countryCode].Exclusions[provinceCode]; !ok { - db.Distributors[distributor][countryCode].Exclusions[provinceCode] = permissionDataInProvince{ - PermissionType: custom, - Inclusions: make(map[string]bool), - Exclusions: make(map[string]bool), + case CITY: + if db.Distributors[distributor].includedCountries[countryCode] { + //check if the province is excluded + if _, exists := db.Distributors[distributor].excludedProvinces[countryCode]; exists && db.Distributors[distributor].excludedProvinces[countryCode][provinceCode] { + //if the province is excluded, then the city should not be in included list + if _, exists := db.Distributors[distributor].includedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].includedCities[countryCode][provinceCode]; exists { + delete(db.Distributors[distributor].includedCities[countryCode][provinceCode], cityCode) + } + } + } else { //if the province is not excluded, then the city should be in excluded list + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].excludedCities[countryCode][provinceCode]; exists { + db.Distributors[distributor].excludedCities[countryCode][provinceCode][cityCode] = true + } else { + db.Distributors[distributor].excludedCities[countryCode][provinceCode] = map[string]bool{cityCode: true} + } + } else { + db.Distributors[distributor].excludedCities[countryCode] = map[string]map[string]bool{provinceCode: {cityCode: true}} + } + } + } else { //if the country is not included, then either (the city should be in excluded list) or (the province should be in excluded list with no exception for the city) + if _, exists := db.Distributors[distributor].excludedProvinces[countryCode]; exists && db.Distributors[distributor].excludedProvinces[countryCode][provinceCode] { + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].excludedCities[countryCode][provinceCode]; exists { + db.Distributors[distributor].excludedCities[countryCode][provinceCode][cityCode] = true + } else { + db.Distributors[distributor].excludedCities[countryCode][provinceCode] = map[string]bool{cityCode: true} + } + } else { + db.Distributors[distributor].excludedCities[countryCode] = map[string]map[string]bool{provinceCode: {cityCode: true}} + } + } else { //if the province is not excluded, then the city should be in excluded list + if _, exists := db.Distributors[distributor].excludedCities[countryCode]; exists { + if _, exists := db.Distributors[distributor].excludedCities[countryCode][provinceCode]; exists { + db.Distributors[distributor].excludedCities[countryCode][provinceCode][cityCode] = true + } else { + db.Distributors[distributor].excludedCities[countryCode][provinceCode] = map[string]bool{cityCode: true} + } + } else { + db.Distributors[distributor].excludedCities[countryCode] = map[string]map[string]bool{provinceCode: {cityCode: true}} + } + } + } + } - db.Distributors[distributor][countryCode].Exclusions[provinceCode].Exclusions[cityCode] = false - return } func (db *DataBank) AddDistributor(distributor string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() - if _, ok := db.Distributors[distributor]; ok { + if db.distributorExists(distributor) { return response.CreateError(400, "DISTRIBUTOR_EXISTS", ErrDistributorExists) } - db.Distributors[distributor] = make(permissionDataGlobal) + db.mu.Lock() + defer db.mu.Unlock() + db.Distributors[distributor] = newPermissionData() return createdResponse } func (db *DataBank) RemoveDistributor(distributor string) response.Response { - db.mu.Lock() - defer db.mu.Unlock() - if _, ok := db.Distributors[distributor]; !ok { + if !db.distributorExists(distributor) { return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, ErrDistributorNotFound) } + + db.mu.Lock() + defer db.mu.Unlock() delete(db.Distributors, distributor) + return successResponse } -func (db *DataBank) ApplyContract(distributorHeirarchy, includeRegions, excludeRegions []string) response.Response { - if len(distributorHeirarchy) > 1 { - //ensure that they have required permission - for i := 1; i < len(distributorHeirarchy); i++ { //skip the first distributor as it may be a new distributor - if _, ok := db.Distributors[distributorHeirarchy[i]]; !ok { - return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("parent distributor %s not found", distributorHeirarchy[i])) +func (db *DataBank) isAllowedForTheDistributor(distributor string, region regions.Region) (bool, string) { + const ( + PARTIALLY_ALLOWED = "PARTIALLY_ALLOWED" + FULLY_ALLOWED = "FULLY_ALLOWED" + FULLY_DENIED = "FULLY_DENIED" + ) + db.mu.RLock() + defer db.mu.RUnlock() + permissionData, ok := db.Distributors[distributor] + if !ok { + return false, "" + } + + countryCode, provinceCode, cityCode, regionType := region.CountryCode, region.ProvinceCode, region.CityCode, region.Type + + switch regionType { + case COUNTRY: + if permissionData.includedCountries[countryCode] { + if len(permissionData.excludedProvinces[countryCode]) > 0 { + return false, PARTIALLY_ALLOWED + } + if len(permissionData.excludedCities[countryCode]) > 0 { + return false, PARTIALLY_ALLOWED + } + return true, FULLY_ALLOWED + } else { + if len(permissionData.includedProvinces[countryCode]) > 0 { + return false, PARTIALLY_ALLOWED + } + for provinceCode := range permissionData.includedCities[countryCode] { + if len(permissionData.includedCities[countryCode][provinceCode]) > 0 { + return false, PARTIALLY_ALLOWED + } } + return false, FULLY_DENIED } - - for _, region := range includeRegions { - countryCode, provinceCode, cityCode, regionType, err := regions.GetRegionDetails(region) - if err != nil { - return response.CreateError(404, REGION_NOT_FOUND, err) + case PROVINCE: + if permissionData.includedCountries[countryCode] { + if _, exists := permissionData.excludedProvinces[countryCode]; exists && permissionData.excludedProvinces[countryCode][provinceCode] { + return false, FULLY_DENIED } - - //check if the region is allowed for the immediate parent distributor - isAllowedForImmediateParent := db.isAllowedForTheDistributor(distributorHeirarchy[1], countryCode, provinceCode, cityCode, regionType) - if !isAllowedForImmediateParent { - return response.CreateError(200, "DISTRIBUTION_NOT_ALLOWED", fmt.Errorf("distribution not allowed for the immediate parent(%s) in region %s which is mentioned in 'INCLUDE'", distributorHeirarchy[1], region)) + if _, exists := permissionData.excludedCities[countryCode]; exists && len(permissionData.excludedCities[countryCode][provinceCode]) > 0 { + return false, PARTIALLY_ALLOWED + } + return true, FULLY_ALLOWED + } else { + if _, exists := permissionData.includedProvinces[countryCode]; exists && permissionData.includedProvinces[countryCode][provinceCode] { + if _, exists := permissionData.excludedCities[countryCode]; exists && len(permissionData.excludedCities[countryCode][provinceCode]) > 0 { + return false, PARTIALLY_ALLOWED + } + return true, FULLY_ALLOWED + } else { + if _, exists := permissionData.includedCities[countryCode]; exists { + if _, exists := permissionData.includedCities[countryCode][provinceCode]; exists && len(permissionData.includedCities[countryCode][provinceCode]) > 0 { + return false, PARTIALLY_ALLOWED + } + } } } - } - - //validate the regions mentioned in the contract - for _, region := range includeRegions { - _, _, _, _, err := regions.GetRegionDetails(region) - if err != nil { - return response.CreateError(404, REGION_NOT_FOUND, err) + case CITY: + if permissionData.includedCountries[countryCode] { + if _, exists := permissionData.excludedProvinces[countryCode]; exists && permissionData.excludedProvinces[countryCode][provinceCode] { + return false, FULLY_DENIED + } + if _, exists := permissionData.excludedCities[countryCode]; exists { + if _, exists := permissionData.excludedCities[countryCode][provinceCode]; exists && permissionData.excludedCities[countryCode][provinceCode][cityCode] { + return false, FULLY_DENIED + } + return true, FULLY_ALLOWED + } else { + if _, exists := permissionData.includedProvinces[countryCode]; exists && permissionData.includedProvinces[countryCode][provinceCode] { + if _, exists := permissionData.excludedCities[countryCode]; exists { + if _, exists := permissionData.excludedCities[countryCode][provinceCode]; exists && permissionData.excludedCities[countryCode][provinceCode][cityCode] { + return false, FULLY_DENIED + } + } + return true, FULLY_ALLOWED + } + if _, exists := permissionData.includedCities[countryCode]; exists { + if _, exists := permissionData.includedCities[countryCode][provinceCode]; exists && permissionData.includedCities[countryCode][provinceCode][cityCode] { + return true, FULLY_ALLOWED + } + } + return false, FULLY_DENIED + } } } - for _, region := range excludeRegions { - _, _, _, _, err := regions.GetRegionDetails(region) - if err != nil { - return response.CreateError(404, REGION_NOT_FOUND, err) - } - } + return false, "UNKNOWN" //this should never happen, as the region type is already validated +} - if _, ok := db.Distributors[distributorHeirarchy[0]]; !ok { - db.Distributors[distributorHeirarchy[0]] = make(permissionDataGlobal) +func (db *DataBank) GetDistributors() response.Response { + db.mu.RLock() + defer db.mu.RUnlock() + distributors := make([]string, 0, len(db.Distributors)) + for distributor := range db.Distributors { + distributors = append(distributors, distributor) } + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "distributors": distributors, + }) +} - //apply the contract - for _, includeRegion := range includeRegions { - countryCode, provinceCode, cityCode, regionType, _ := regions.GetRegionDetails(includeRegion) - db.markAsIncluded(distributorHeirarchy[0], countryCode, provinceCode, cityCode, regionType) +func (db *DataBank) CheckIfDistributionIsAllowed(distributor, regionString string) response.Response { + region, err := regions.GetRegionDetails(regionString) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) } - for _, excludeRegion := range excludeRegions { - countryCode, provinceCode, cityCode, regionType, _ := regions.GetRegionDetails(excludeRegion) - if !db.isAllowedForTheDistributor(distributorHeirarchy[0], countryCode, provinceCode, cityCode, regionType) { - continue //if the region is already in allow list(possibly by other contracts), then no need to exclude it - } else { - db.markAsExcluded(distributorHeirarchy[0], countryCode, provinceCode, cityCode, regionType) - } + if !db.distributorExists(distributor) { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) } - return successResponse + _, status := db.isAllowedForTheDistributor(distributor, region) + return response.CreateSuccess(200, status, nil) } -func (db *DataBank) isAllowedForTheDistributor(distributor, countryCode, provinceCode, cityCode, regionType string) bool { +func (db *DataBank) distributorExists(distributor string) bool { db.mu.RLock() defer db.mu.RUnlock() - permissionDataGlobally, ok := db.Distributors[distributor] + if _, ok := db.Distributors[distributor]; ok { + return true + } + return false +} + +// func (db *DataBank) getParentRegions(distributor string) ([]regions.Region, []regions.Region) { +// return nil, nil +// } + +func (db *DataBank) getDistributorPermissionCopy(distributor string) (permissionData, bool) { + db.mu.RLock() + defer db.mu.RUnlock() + if permissionData, ok := db.Distributors[distributor]; ok { + return permissionData.copyPermissionData(), true + } + return permissionData{}, false +} + +func (db *DataBank) GetDistributorPermissionsAsText(distributor string) string { + permissionData, ok := db.getDistributorPermissionCopy(distributor) if !ok { - return false + return "Distributor not found" } - if permissionDataInCountry, exists := permissionDataGlobally[countryCode]; exists { - switch permissionDataInCountry.PermissionType { - case allowAll: - return true - case denyAll: - return false - default: - if regionType == COUNTRY { - return false + builder := new(strings.Builder) + builder.WriteString("Permissions for " + distributor) + + for country := range permissionData.includedCountries { + builder.WriteString("\nINCLUDE: " + country) + } + for country := range permissionData.includedProvinces { + for province := range permissionData.includedProvinces[country] { + builder.WriteString("\nINCLUDE: " + province + "-" + country) + } + } + for country := range permissionData.includedCities { + for province := range permissionData.includedCities[country] { + for city := range permissionData.includedCities[country][province] { + builder.WriteString("\nINCLUDE: " + city + "-" + province + "-" + country) } } - if permissionDataInProvince, exists := permissionDataInCountry.Inclusions[provinceCode]; exists { - switch permissionDataInProvince.PermissionType { - case allowAll: - return true - case denyAll: - return false - default: - if regionType == PROVINCE { - return false - } - if permissionDataInProvince.Inclusions[cityCode] { - return true - } + } + + for country := range permissionData.excludedProvinces { + for province := range permissionData.excludedProvinces[country] { + builder.WriteString("\nEXCLUDE: " + province + "-" + country) + } + } + for country := range permissionData.excludedCities { + for province := range permissionData.excludedCities[country] { + for city := range permissionData.excludedCities[country][province] { + builder.WriteString("\nEXCLUDE: " + city + "-" + province + "-" + country) } } } - return false + + return builder.String() } -func (db *DataBank) GetDistributors() response.Response { - db.mu.RLock() - defer db.mu.RUnlock() - distributors := make([]string, 0, len(db.Distributors)) - for distributor := range db.Distributors { - distributors = append(distributors, distributor) +func (db *DataBank) GetDistributorPermissionAsJSON(distributor string) response.Response { + permissionData, ok := db.getDistributorPermissionCopy(distributor) + if !ok { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) } - return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ - "distributors": distributors, - }) -} \ No newline at end of file + + inclusions := make([]string, 0, len(permissionData.includedCountries)+len(permissionData.includedProvinces)+len(permissionData.includedCities)) + exclusions := make([]string, 0, len(permissionData.excludedProvinces)+len(permissionData.excludedCities)) + + for country := range permissionData.includedCountries { + inclusions = append(inclusions, country) + } + for country := range permissionData.includedProvinces { + for province := range permissionData.includedProvinces[country] { + inclusions = append(inclusions, province+"-"+country) + } + } + for country := range permissionData.includedCities { + for province := range permissionData.includedCities[country] { + for city := range permissionData.includedCities[country][province] { + inclusions = append(inclusions, city+"-"+province+"-"+country) + } + } + } + + for country := range permissionData.excludedProvinces { + for province := range permissionData.excludedProvinces[country] { + exclusions = append(exclusions, province+"-"+country) + } + } + + for country := range permissionData.excludedCities { + for province := range permissionData.excludedCities[country] { + for city := range permissionData.excludedCities[country][province] { + exclusions = append(exclusions, city+"-"+province+"-"+country) + } + } + } + + resp := struct { + Distributor string + Included []string + Excluded []string + }{ + Distributor: distributor, + Included: inclusions, + Excluded: exclusions, + } + + return response.CreateSuccess(200, "SUCCESS", resp) +} diff --git a/internal/data/process_contract.go b/internal/data/process_contract.go new file mode 100644 index 000000000..4130d7929 --- /dev/null +++ b/internal/data/process_contract.go @@ -0,0 +1,478 @@ +package data + +import ( + "challenge16/internal/dto" + "challenge16/internal/response" + "fmt" +) + +func mergeMapIntoMap[A map[string]bool](to, from A) { + for k, v := range from { + to[k] = v + } +} + +// filterContractPermissionsBasedOnParentPermissions filters the contract permissions based on the parent permissions. +// It removes the regions that are not included in the parent permissions, but included in the contract permissions. +// It also removes the regions that are excluded in the parent permissions, but not excluded in the contract permissions. +// It also removes the regions that are excluded in the parent permissions, but included in the contract permissions. +// If parent is nil or not existing, it does nothing. +func (db *DataBank) filterContractPermissionsBasedOnParentPermissions(contract dto.Contract) { + if contract.ParentDistributor == nil { + return + } + + parentPermission, ok := db.getDistributorPermissionCopy(*contract.ParentDistributor) + if !ok { + return + } + + contractPermissions := contract.Permissions + + for country := range contractPermissions.IncludedCountries { + if parentPermission.includedCountries[country] { + continue + } else { + delete(contractPermissions.IncludedCountries, country) + if _, exists := contractPermissions.IncludedProvinces[country]; !exists { + contractPermissions.IncludedProvinces[country] = make(map[string]bool) + } + for parentProvince := range parentPermission.includedProvinces[country] { + contractPermissions.IncludedProvinces[country][parentProvince] = true + } + if _, exists := contractPermissions.IncludedCities[country]; !exists { + contractPermissions.IncludedCities[country] = make(map[string]map[string]bool) + for parentProvince := range parentPermission.includedCities[country] { + if _, exists := contractPermissions.IncludedCities[country][parentProvince]; !exists { + contractPermissions.IncludedCities[country][parentProvince] = make(map[string]bool) + } + for parentCity := range parentPermission.includedCities[country][parentProvince] { + contractPermissions.IncludedCities[country][parentProvince][parentCity] = true + } + } + } + } + } + + for country := range contractPermissions.IncludedProvinces { + if parentPermission.includedCountries[country] { + continue + } + for province := range contractPermissions.IncludedProvinces[country] { + if _, exists := parentPermission.includedProvinces[country]; exists && parentPermission.includedProvinces[country][province] { + continue + } else { + delete(contractPermissions.IncludedProvinces[country], province) + if _, exists := parentPermission.includedCities[country]; exists { + if _, exists := contractPermissions.IncludedCities[country]; !exists { + contractPermissions.IncludedCities[country] = make(map[string]map[string]bool) + } + if _, exists := contractPermissions.IncludedCities[country][province]; !exists { + contractPermissions.IncludedCities[country][province] = make(map[string]bool) + } + for parentCity := range parentPermission.includedCities[country][province] { + contractPermissions.IncludedCities[country][province][parentCity] = true + } + } + } + } + } + + for country := range contractPermissions.IncludedCities { + if parentPermission.includedCountries[country] { + continue + } + for province := range contractPermissions.IncludedCities[country] { + if _, exists := parentPermission.includedProvinces[country]; exists && parentPermission.includedProvinces[country][province] { + continue + } + for city := range contractPermissions.IncludedCities[country][province] { + if _, exists := parentPermission.includedCities[country]; exists { + if _, exists := parentPermission.includedCities[country][province]; exists && parentPermission.includedCities[country][province][city] { + continue + } else { + delete(contractPermissions.IncludedCities[country][province], city) + } + } else { + delete(contractPermissions.IncludedCities[country][province], city) + } + } + } + } + + //merging applicable excluded regions + //provincial level exclusion + for country := range parentPermission.excludedProvinces { + if contractPermissions.IncludedCountries[country] { + mergeMapIntoMap(contractPermissions.ExcludedProvinces[country], parentPermission.excludedProvinces[country]) + continue + } + if _, exists := contractPermissions.IncludedProvinces[country]; exists { + for parentExcludedProvince := range parentPermission.excludedProvinces[country] { + //delete the province from included provinces if it is excluded in parent + delete(contractPermissions.IncludedProvinces[country], parentExcludedProvince) + + //no need of city level exclusion for cities in this province as the province is excluded + if _, exists := contractPermissions.ExcludedCities[country]; exists { + delete(contractPermissions.ExcludedCities[country], parentExcludedProvince) + } + } + } + + if _, exists := contractPermissions.IncludedCities[country]; exists { + //delete the cities in province from included cities as the province is excluded in parent + for parentExcludedProvince := range parentPermission.excludedProvinces[country] { + delete(contractPermissions.IncludedCities[country], parentExcludedProvince) + } + } + } + + //city level exclusion + for country := range parentPermission.excludedCities { + for province := range parentPermission.excludedCities[country] { + for city := range parentPermission.excludedCities[country][province] { + if contractPermissions.IncludedCountries[country] { + if _, exists := contractPermissions.ExcludedProvinces[country]; !exists || !contractPermissions.ExcludedProvinces[country][province] { + //if the province is not excluded in contract, but country is included, then exclude the city + if _, exists := contractPermissions.ExcludedCities[country]; !exists { + contractPermissions.ExcludedCities[country] = make(map[string]map[string]bool) + } + if _, exists := contractPermissions.ExcludedCities[country][province]; !exists { + contractPermissions.ExcludedCities[country][province] = make(map[string]bool) + } + contractPermissions.ExcludedCities[country][province][city] = true + } + } else { + if _, exists := contractPermissions.IncludedProvinces[country]; exists && contractPermissions.IncludedProvinces[country][province] { + contractPermissions.ExcludedCities[country][province][city] = true + } else { + if _, exists := contractPermissions.IncludedCities[country]; exists { + if _, exists := contractPermissions.IncludedCities[country][province]; exists { + delete(contractPermissions.IncludedCities[country][province], city) + } + } + } + } + } + } + } + + //city level exclusion + for country := range parentPermission.excludedCities { + for province := range parentPermission.excludedCities[country] { + for city := range parentPermission.excludedCities[country][province] { + if contractPermissions.IncludedCountries[country] { + if _, exists := contractPermissions.ExcludedProvinces[country]; !exists || !contractPermissions.ExcludedProvinces[country][province] { + //=> the province is not excluded in contract, but country is included. So, exclude the city(if not already excluded) + + if _, exists := contractPermissions.ExcludedCities[country]; !exists { + contractPermissions.ExcludedCities[country] = make(map[string]map[string]bool) + } + if _, exists := contractPermissions.ExcludedCities[country][province]; !exists { + contractPermissions.ExcludedCities[country][province] = make(map[string]bool) + } + contractPermissions.ExcludedCities[country][province][city] = true + } + } else { + if _, exists := contractPermissions.IncludedProvinces[country]; exists && contractPermissions.IncludedProvinces[country][province] { + //=> the province is included in contract. So, exclude the city(if not already excluded) + contractPermissions.ExcludedCities[country][province][city] = true + } else { + //=> the province is not included in contract. So, there wont be any exclusions required for cities in this province. + //=> But, as country and province are not included, these cities may be in included list. So, we need to remove the city from included cities(if it is included) + if _, exists := contractPermissions.IncludedCities[country]; exists { + if _, exists := contractPermissions.IncludedCities[country][province]; exists { + delete(contractPermissions.IncludedCities[country][province], city) + } + } + } + } + } + } + } + + contract.Permissions = contractPermissions +} + +func validateContract(contract dto.Contract) error { + // Validate the contract here + //if a region is included, sub regions should only be of 'excluded' type + for country := range contract.IncludedCountries { + if _, exists := contract.IncludedProvinces[country]; exists && len(contract.IncludedProvinces[country]) > 0 { + return fmt.Errorf("country %s is included, but provinces are also included. There should only be exclusions of sub-regions for an included region", country) + } + for province := range contract.IncludedCities[country] { + if len(contract.IncludedCities[country][province]) > 0 { + return fmt.Errorf("country %s is included, but cities in province %s are also included. There should only be exclusions of sub-regions for an included region", country, province) + } + } + } + + for country := range contract.IncludedProvinces { + for province := range contract.IncludedProvinces[country] { + if _, exists := contract.IncludedCities[country]; exists && len(contract.IncludedCities[country][province]) > 0 { + return fmt.Errorf("province %s in country %s is included, but cities are also included. There should only be exclusions of sub-regions for an included region", province, country) + } + + //same province should not be included and excluded + if _, exists := contract.ExcludedProvinces[country]; exists && contract.ExcludedProvinces[country][province] { + return fmt.Errorf("province %s in country %s is included and excluded. It should be either included or excluded", province, country) + } + } + } + + for country := range contract.IncludedCities { + for province := range contract.IncludedCities[country] { + for city := range contract.IncludedCities[country][province] { + //same city should not be included and excluded + if _, exists := contract.ExcludedCities[country]; exists { + if _, exists := contract.ExcludedCities[country][province]; exists && contract.ExcludedCities[country][province][city] { + return fmt.Errorf("city %s in province %s in country %s is included and excluded. It should be either included or excluded", city, province, country) + } + } + } + } + } + + return nil +} + +func (db *DataBank) applyContractOnDistributor(finalContract dto.Contract) { + recipient := finalContract.ContractRecipient + + db.createDistributorIfNotExists(recipient) + + oldPermissionData, _ := db.getDistributorPermissionCopy(recipient) + newPermissionData := oldPermissionData.copyPermissionData() + + //merge included countries + mergeMapIntoMap(newPermissionData.includedCountries, finalContract.IncludedCountries) + + //merge included provinces + for country, provinces := range finalContract.IncludedProvinces { + if _, exists := newPermissionData.includedProvinces[country]; !exists { + newPermissionData.includedProvinces[country] = make(map[string]bool) + } + mergeMapIntoMap(newPermissionData.includedProvinces[country], provinces) + } + + //merge included cities + for country := range finalContract.IncludedCities { + if _, exists := newPermissionData.includedCities[country]; !exists { + newPermissionData.includedCities[country] = make(map[string]map[string]bool) + } + for province, cities := range finalContract.IncludedCities[country] { + if _, exists := newPermissionData.includedCities[country][province]; !exists { + newPermissionData.includedCities[country][province] = make(map[string]bool) + } + mergeMapIntoMap(newPermissionData.includedCities[country][province], cities) + } + } + + finalExcludedProvinces := map[string]map[string]bool{} + + // finding exclusions that are excluded for one, but not included for the other + for country := range finalContract.ExcludedProvinces { + for province := range finalContract.ExcludedProvinces[country] { + /* + possiblity of inclusions: + country included,province not excluded=>included province + country not included,province included=>included province + */ + isIncludedInOther := false + if oldPermissionData.includedCountries[country] { + if _, exists := oldPermissionData.excludedProvinces[country]; !exists || !oldPermissionData.excludedProvinces[country][province] { + isIncludedInOther = true + } + } else { + if _, exists := oldPermissionData.includedProvinces[country]; exists && oldPermissionData.includedProvinces[country][province] { + isIncludedInOther = true + } + } + if !isIncludedInOther { + if _, exists := finalExcludedProvinces[country]; !exists { + finalExcludedProvinces[country] = make(map[string]bool) + } + finalExcludedProvinces[country][province] = true + } + } + } + for country := range oldPermissionData.excludedProvinces { + for province := range oldPermissionData.excludedProvinces[country] { + isIncludedInOther := false + if finalContract.IncludedCountries[country] { + if _, exists := finalContract.ExcludedProvinces[country]; !exists || !finalContract.ExcludedProvinces[country][province] { + isIncludedInOther = true + } + } else { + if _, exists := finalContract.IncludedProvinces[country]; exists && finalContract.IncludedProvinces[country][province] { + isIncludedInOther = true + } + } + if !isIncludedInOther { + if _, exists := finalExcludedProvinces[country]; !exists { + finalExcludedProvinces[country] = make(map[string]bool) + } + finalExcludedProvinces[country][province] = true + } + } + } + + finalExcludedCities := map[string]map[string]map[string]bool{} + + //merge commonly excluded cities + /* + possiblity of inclusions: + country included, province not excluded and city not excluded => included city + country not included, province included and city not excluded => included city + country not included, province not included and city included => included city + */ + for country := range finalContract.ExcludedCities { + for province := range finalContract.ExcludedCities[country] { + for city := range finalContract.ExcludedCities[country][province] { + isIncludedInOther := true + if oldPermissionData.includedCountries[country] { + if _, exists := oldPermissionData.excludedProvinces[country]; !exists || !oldPermissionData.excludedProvinces[country][province] { + if _, exists := oldPermissionData.excludedCities[country]; exists { + if _, exists := oldPermissionData.excludedCities[country][province]; exists && oldPermissionData.excludedCities[country][province][city] { + isIncludedInOther = false + } + } + } else { + isIncludedInOther = false + } + } else { + if _, exists := oldPermissionData.includedProvinces[country]; exists && oldPermissionData.includedProvinces[country][province] { + if _, exists := oldPermissionData.excludedCities[country]; exists { + if _, exists := oldPermissionData.excludedCities[country][province]; exists && oldPermissionData.excludedCities[country][province][city] { + isIncludedInOther = false + } + } + } else { + if _, exists := oldPermissionData.includedCities[country]; exists { + if _, exists := oldPermissionData.includedCities[country][province]; !exists || !oldPermissionData.includedCities[country][province][city] { + isIncludedInOther = false + } + } else { + isIncludedInOther = false + } + } + } + + if !isIncludedInOther { + if _, exists := finalExcludedCities[country]; !exists { + finalExcludedCities[country] = make(map[string]map[string]bool) + } + if _, exists := finalExcludedCities[country][province]; !exists { + finalExcludedCities[country][province] = make(map[string]bool) + } + finalExcludedCities[country][province][city] = true + } + } + } + } + + for country := range oldPermissionData.excludedCities { + for province := range oldPermissionData.excludedCities[country] { + for city := range oldPermissionData.excludedCities[country][province] { + isIncludedInOther := true + if finalContract.IncludedCountries[country] { + if _, exists := finalContract.ExcludedProvinces[country]; !exists || !finalContract.ExcludedProvinces[country][province] { + if _, exists := finalContract.ExcludedCities[country]; exists { + if _, exists := finalContract.ExcludedCities[country][province]; exists && finalContract.ExcludedCities[country][province][city] { + isIncludedInOther = false + } + } + } else { + isIncludedInOther = false + } + } else { + if _, exists := finalContract.IncludedProvinces[country]; exists && finalContract.IncludedProvinces[country][province] { + if _, exists := finalContract.ExcludedCities[country]; exists { + if _, exists := finalContract.ExcludedCities[country][province]; exists && finalContract.ExcludedCities[country][province][city] { + isIncludedInOther = false + } + } + } else { + if _, exists := finalContract.IncludedCities[country]; exists { + if _, exists := finalContract.IncludedCities[country][province]; !exists || !finalContract.IncludedCities[country][province][city] { + isIncludedInOther = false + } + } else { + isIncludedInOther = false + } + } + } + + if !isIncludedInOther { + if _, exists := finalExcludedCities[country]; !exists { + finalExcludedCities[country] = make(map[string]map[string]bool) + } + if _, exists := finalExcludedCities[country][province]; !exists { + finalExcludedCities[country][province] = make(map[string]bool) + } + finalExcludedCities[country][province][city] = true + } + + } + } + + } + + for country := range finalContract.ExcludedCities { + if _, exists := oldPermissionData.excludedCities[country]; !exists { + continue + } + for province := range finalContract.ExcludedCities[country] { + if _, exists := oldPermissionData.excludedCities[country][province]; !exists { + continue + } + for city := range finalContract.ExcludedCities[country][province] { + if oldPermissionData.excludedCities[country][province][city] { + if _, exists := finalExcludedCities[country]; !exists { + finalExcludedCities[country] = make(map[string]map[string]bool) + } + if _, exists := finalExcludedCities[country][province]; !exists { + finalExcludedCities[country][province] = make(map[string]bool) + } + finalExcludedCities[country][province][city] = true + } + } + } + } + + //replace the existing data with the new data + newPermissionData.excludedProvinces = finalExcludedProvinces + newPermissionData.excludedCities = finalExcludedCities + + //replace the recipient's permission data with the new data + db.mu.Lock() + defer db.mu.Unlock() + db.Distributors[recipient] = newPermissionData +} + +func (db *DataBank) ApplyContract(contract dto.Contract) response.Response { + + err := validateContract(contract) + if err != nil { + return response.CreateError(400, "INVALID_CONTRACT", fmt.Errorf("invalid contract, err: %v", err)) + } + + if contract.ParentDistributor != nil { + if !db.distributorExists(*contract.ParentDistributor) { + return response.CreateError(400, "PARENT_DISTRIBUTOR_NOT_FOUND", fmt.Errorf("parent distributor %s not found", *contract.ParentDistributor)) + } + db.filterContractPermissionsBasedOnParentPermissions(contract) + } + + db.applyContractOnDistributor(contract) + return successResponse +} + +func (db *DataBank) createDistributorIfNotExists(distributor string) { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.Distributors[distributor]; !ok { + db.Distributors[distributor] = newPermissionData() + } +} diff --git a/internal/data/types.go b/internal/data/types.go new file mode 100644 index 000000000..ad6e6ee1f --- /dev/null +++ b/internal/data/types.go @@ -0,0 +1,69 @@ +package data + +type permissionData struct { + /* + heirarchy... + country->(if country is mentioned ): -(excludedProvinces) -(excludedCities) + country->(if country is not mentioned ): +(includedProvinces - excludedCities) + (includedCities) + */ + includedCountries map[string]bool + includedProvinces map[string]map[string]bool + excludedProvinces map[string]map[string]bool + includedCities map[string]map[string]map[string]bool + excludedCities map[string]map[string]map[string]bool +} + +func (src permissionData) copyPermissionData() permissionData { + dst := permissionData{ + includedCountries: make(map[string]bool), + includedProvinces: make(map[string]map[string]bool), + excludedProvinces: make(map[string]map[string]bool), + includedCities: make(map[string]map[string]map[string]bool), + excludedCities: make(map[string]map[string]map[string]bool), + } + + // Copy includedCountries + for k, v := range src.includedCountries { + dst.includedCountries[k] = v + } + + // Copy includedProvinces + for k, v := range src.includedProvinces { + dst.includedProvinces[k] = make(map[string]bool) + for k2, v2 := range v { + dst.includedProvinces[k][k2] = v2 + } + } + + // Copy excludedProvinces + for k, v := range src.excludedProvinces { + dst.excludedProvinces[k] = make(map[string]bool) + for k2, v2 := range v { + dst.excludedProvinces[k][k2] = v2 + } + } + + // Copy includedCities + for country, provinces := range src.includedCities { + dst.includedCities[country] = make(map[string]map[string]bool) + for province, cities := range provinces { + dst.includedCities[country][province] = make(map[string]bool) + for city, v := range cities { + dst.includedCities[country][province][city] = v + } + } + } + + // Copy excludedCities + for country, provinces := range src.excludedCities { + dst.excludedCities[country] = make(map[string]map[string]bool) + for province, cities := range provinces { + dst.excludedCities[country][province] = make(map[string]bool) + for city, v := range cities { + dst.excludedCities[country][province][city] = v + } + } + } + + return dst +} diff --git a/internal/dto/dto.go b/internal/dto/dto.go new file mode 100644 index 000000000..5c16a32b9 --- /dev/null +++ b/internal/dto/dto.go @@ -0,0 +1,84 @@ +package dto + +import "challenge16/internal/regions" + +type ( + // Contract struct { + // ParentDistributor string + // SubDistributor *string + // IncludedRegions []string + // ExcludedRegions []string + // } + + Contract struct { + /* + heirarchy... + country->(if country is mentioned ): (-excludedProvinces) - (excludedCities) + country->(if country is not mentioned ): ( includedProvinces - excludedCities) + (includedCities) + */ + + ParentDistributor *string + ContractRecipient string + Permissions + } + + Permissions struct { + IncludedCountries map[string]bool + IncludedProvinces map[string]map[string]bool + ExcludedProvinces map[string]map[string]bool + IncludedCities map[string]map[string]map[string]bool + ExcludedCities map[string]map[string]map[string]bool + } +) + +func (c *Contract) AddIncludedRegion(regionString string) error { + region, err := regions.GetRegionDetails(regionString) + if err != nil { + return err + } + + switch region.Type { + case regions.COUNTRY: + c.IncludedCountries[region.CountryCode] = true + case regions.PROVINCE: + if c.IncludedProvinces[region.CountryCode] == nil { + c.IncludedProvinces[region.CountryCode] = make(map[string]bool) + } + c.IncludedProvinces[region.CountryCode][region.ProvinceCode] = true + case regions.CITY: + if c.IncludedCities[region.CountryCode] == nil { + c.IncludedCities[region.CountryCode] = make(map[string]map[string]bool) + } + if c.IncludedCities[region.CountryCode][region.ProvinceCode] == nil { + c.IncludedCities[region.CountryCode][region.ProvinceCode] = make(map[string]bool) + } + c.IncludedCities[region.CountryCode][region.ProvinceCode][region.CityCode] = true + } + return nil +} + +func (c *Contract) AddExcludedRegion(regionString string) error { + region, err := regions.GetRegionDetails(regionString) + if err != nil { + return err + } + + switch region.Type { + case regions.COUNTRY: + c.IncludedCountries[region.CountryCode] = false + case regions.PROVINCE: + if c.ExcludedProvinces[region.CountryCode] == nil { + c.ExcludedProvinces[region.CountryCode] = make(map[string]bool) + } + c.ExcludedProvinces[region.CountryCode][region.ProvinceCode] = true + case regions.CITY: + if c.ExcludedCities[region.CountryCode] == nil { + c.ExcludedCities[region.CountryCode] = make(map[string]map[string]bool) + } + if c.ExcludedCities[region.CountryCode][region.ProvinceCode] == nil { + c.ExcludedCities[region.CountryCode][region.ProvinceCode] = make(map[string]bool) + } + c.ExcludedCities[region.CountryCode][region.ProvinceCode][region.CityCode] = true + } + return nil +} diff --git a/internal/handler/distributer.go b/internal/handler/distributer.go index 951e94346..898ebaf47 100644 --- a/internal/handler/distributer.go +++ b/internal/handler/distributer.go @@ -31,7 +31,7 @@ func (h *handler) RemoveDistributor(c *fiber.Ctx) error { return resp.WriteToJSON(c) } -func (h *handler) GetDistributers(c *fiber.Ctx) error { +func (h *handler) GetDistributors(c *fiber.Ctx) error { resp := h.databank.GetDistributors() return resp.WriteToJSON(c) -} \ No newline at end of file +} diff --git a/internal/handler/permission.go b/internal/handler/permission.go index 05ef307d8..793c6fa71 100644 --- a/internal/handler/permission.go +++ b/internal/handler/permission.go @@ -1,6 +1,7 @@ package handler import ( + "challenge16/internal/dto" "challenge16/internal/response" "challenge16/validation" "errors" @@ -21,8 +22,8 @@ func (h *handler) CheckIfDistributionIsAllowed(c *fiber.Ctx) error { return err } - // return resp.WriteToJSON(c) - return nil + resp := h.databank.CheckIfDistributionIsAllowed(req.Distributor, req.RegionString) + return resp.WriteToJSON(c) } func (h *handler) AllowDistribution(c *fiber.Ctx) error { @@ -54,8 +55,8 @@ func (h *handler) DisallowDistribution(c *fiber.Ctx) error { } func (h *handler) ApplyContract(c *fiber.Ctx) error { - contract := string(c.Body()) - distributorHeirarchy, includeRegions, excludeRegions, err := getContractData(contract) + contractText := string(c.Body()) + contract, err := getContractData(contractText) if err != nil { return response.Response{ HttpStatusCode: 400, @@ -63,11 +64,11 @@ func (h *handler) ApplyContract(c *fiber.Ctx) error { Error: err, }.WriteToJSON(c) } - resp := h.databank.ApplyContract(distributorHeirarchy, includeRegions, excludeRegions) + resp := h.databank.ApplyContract(*contract) return resp.WriteToJSON(c) } -func getContractData(contract string) (distributorHeirarchy, includeRegions, excludeRegions []string, err error) { +func getContractData(contractText string) (*dto.Contract, error) { //Example contract: /* Permissions for DISTRIBUTOR1 @@ -83,30 +84,56 @@ func getContractData(contract string) (distributorHeirarchy, includeRegions, exc Permissions for DISTRIBUTOR1 < DISTRIBUTOR2 < DISTRIBUTOR3 INCLUDE: YADGR-KA-IN */ - - contractData := strings.Split(contract, "\n") + var ( + contract = dto.Contract{ + Permissions: dto.Permissions{ + IncludedCountries: make(map[string]bool), + IncludedProvinces: make(map[string]map[string]bool), + IncludedCities: make(map[string]map[string]map[string]bool), + ExcludedProvinces: make(map[string]map[string]bool), + ExcludedCities: make(map[string]map[string]map[string]bool), + }, + } + err error + ) + contractData := strings.Split(contractText, "\n") if len(contractData) < 2 { err = errors.New("Invalid contract, regions not found") - return + return nil, err } heading := strings.TrimLeft(contractData[0], " ") if !strings.HasPrefix(heading, "Permissions for ") { err = errors.New("Invalid contract, heading line: Prefix: 'Permissions for ' not found") - return - } else { - data := strings.TrimPrefix(heading, "Permissions for ") - data = strings.TrimRight(data, " ") //to avoid spaces at the end made by mistake - distributorHeirarchy = strings.Split(data, " < ") - if len(distributorHeirarchy) == 0 { - err = errors.New("Invalid contract, distributor(s) not found in heading line after 'Permissions for': " + data) - return - } else if len(distributorHeirarchy) == 1 { - if distributorHeirarchy[0] == "" { - err = errors.New("Invalid contract, distributor(s) not found in heading line after 'Permissions for': " + data) - return - } + return nil, err + } + + distributorHeirarchyText := strings.TrimPrefix(heading, "Permissions for ") + distributorHeirarchyText = strings.ReplaceAll(distributorHeirarchyText, " ", "") //Remove spaces + distributorHeirarchy := strings.Split(distributorHeirarchyText, "<") + switch len(distributorHeirarchy) { + case 0: + return nil, errors.New("Invalid contract, distributor(s) not found in heading line after 'Permissions for': " + distributorHeirarchyText) + case 1: + if distributorHeirarchy[0] == "" { + return nil, errors.New("Invalid contract, distributor(s) not found in heading line after 'Permissions for': " + distributorHeirarchyText) + } + contract.ContractRecipient = distributorHeirarchy[0] + default: + contract.ParentDistributor = &distributorHeirarchy[1] + contract.ContractRecipient = distributorHeirarchy[0] + } + + //check for duplication in distributor heirarchy, also check for empty strings + distributorMap := make(map[string]bool) + for _, distributor := range distributorHeirarchy { + if distributor == "" { + return nil, errors.New("Invalid contract, empty distributor found in heading line after 'Permissions for': " + distributorHeirarchyText) + } + if _, ok := distributorMap[distributor]; ok { + return nil, errors.New("Invalid contract, duplicate distributor found in heading line after 'Permissions for': " + distributorHeirarchyText) } + distributorMap[distributor] = true } for _, data := range contractData[1:] { @@ -114,24 +141,41 @@ func getContractData(contract string) (distributorHeirarchy, includeRegions, exc switch { case strings.HasPrefix(data, "INCLUDE: "): data = strings.TrimPrefix(data, "INCLUDE: ") - includeRegions = append(includeRegions, data) + err = contract.AddIncludedRegion(data) + if err != nil { + return nil, err + } + case strings.HasPrefix(data, "EXCLUDE: "): data = strings.TrimPrefix(data, "EXCLUDE: ") - excludeRegions = append(excludeRegions, data) + err = contract.AddExcludedRegion(data) + if err != nil { + return nil, err + } default: - err = errors.New("Invalid contract, invalid line found: " + data) + return nil, errors.New("Invalid contract, invalid line found: " + data) } } - if len(distributorHeirarchy) == 0 { - err = errors.New("Invalid contract, distributor(s) not found") - return + if len(contract.IncludedCountries) == 0 && len(contract.IncludedProvinces) == 0 && len(contract.IncludedCities) == 0 { + err = errors.New("Invalid contract, no included regions found in contract") + return nil, err } - if len(includeRegions) == 0 && len(excludeRegions) == 0 { - err = errors.New("Invalid contract, no permissions found") - return + return &contract, nil +} + +func (h *handler) GetDistributorPermissions(c *fiber.Ctx) error { + distributor := c.Params("distributor") + if distributor == "" { + return response.InvalidURLParamResponse("distributor", errors.New("distributor not found in url")).WriteToJSON(c) } - return + if c.Query("type", "text") == "json" { + resp := h.databank.GetDistributorPermissionAsJSON(distributor) + return resp.WriteToJSON(c) + } else { + note := h.databank.GetDistributorPermissionsAsText(distributor) + return c.Status(200).SendString(note) + } } diff --git a/internal/handler/regions.go b/internal/handler/regions.go index 5661f3d69..bae89ece3 100644 --- a/internal/handler/regions.go +++ b/internal/handler/regions.go @@ -8,9 +8,15 @@ import ( "github.com/gofiber/fiber/v2" ) +const ( + INVALID_REGION = "INVALID_REGION" +) + func (h *handler) GetCountries(c *fiber.Ctx) error { - resp := regions.GetCountries() - return resp.WriteToJSON(c) + countries := regions.GetCountries() + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "countries": countries, + }).WriteToJSON(c) } func (h *handler) GetProvincesInCountry(c *fiber.Ctx) error { @@ -18,8 +24,14 @@ func (h *handler) GetProvincesInCountry(c *fiber.Ctx) error { if countryCode == "" { return response.CreateError(400, URL_PARAM_MISSING, fmt.Errorf("Country code is required")).WriteToJSON(c) } - resp := regions.GetProvincesInCountry(countryCode) - return resp.WriteToJSON(c) + if !regions.CheckCountry(countryCode) { + return response.CreateError(400, INVALID_REGION, fmt.Errorf("Invalid country code")).WriteToJSON(c) + } + + provinces := regions.GetProvincesInCountry(countryCode) + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "provinces": provinces, + }).WriteToJSON(c) } func (h *handler) GetCitiesInProvince(c *fiber.Ctx) error { @@ -28,6 +40,14 @@ func (h *handler) GetCitiesInProvince(c *fiber.Ctx) error { if countryCode == "" || provinceCode == "" { return response.CreateError(400, URL_PARAM_MISSING, fmt.Errorf("Country code and province code are required")).WriteToJSON(c) } - resp := regions.GetCitiesInProvince(countryCode, provinceCode) - return resp.WriteToJSON(c) + if !regions.CheckCountry(countryCode) { + return response.CreateError(400, INVALID_REGION, fmt.Errorf("Invalid country code")).WriteToJSON(c) + } + if !regions.CheckProvince(countryCode, provinceCode) { + return response.CreateError(400, INVALID_REGION, fmt.Errorf("Invalid province code")).WriteToJSON(c) + } + cities := regions.GetCitiesInProvince(countryCode, provinceCode) + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "cities": cities, + }).WriteToJSON(c) } diff --git a/internal/regions/check.go b/internal/regions/check.go index 95141821d..c23d4fdd8 100644 --- a/internal/regions/check.go +++ b/internal/regions/check.go @@ -15,54 +15,70 @@ const ( CITY = "city" ) -func checkCountry(countryCode string) bool { +func CheckCountry(countryCode string) bool { _, ok := Countries[countryCode] return ok } -func checkProvince(countryCode, provinceCode string) bool { - if checkCountry(countryCode) == false { +func CheckProvince(countryCode, provinceCode string) bool { + if CheckCountry(countryCode) == false { return false } _, ok := Countries[countryCode].Provinces[provinceCode] return ok } -func checkCity(countryCode, provinceCode, cityCode string) bool { - if checkProvince(countryCode, provinceCode) == false { +func CheckCity(countryCode, provinceCode, cityCode string) bool { + if CheckProvince(countryCode, provinceCode) == false { return false } _, ok := Countries[countryCode].Provinces[provinceCode].Cities[cityCode] return ok } -func GetRegionDetails(regionString string) (countryCode, provinceCode, cityCode, regionType string, err error) { +type Region struct { + CountryCode string + ProvinceCode string + CityCode string + Type string +} + +func GetRegionDetails(regionString string) (Region, error) { + var ( + region Region + err error + countryCode, provinceCode, cityCode, regionType string + ) subStrings := strings.Split(regionString, "-") // Splitting the regionString by "-", this is the regionString I am assuming switch len(subStrings) { case 1: countryCode = subStrings[0] - if !checkCountry(countryCode) { + regionType = COUNTRY + if !CheckCountry(countryCode) { err = errors.New("country not found: " + countryCode) - return } - regionType = COUNTRY case 2: countryCode = subStrings[1] provinceCode = subStrings[0] regionType = PROVINCE - if !checkProvince(countryCode, provinceCode) { + if !CheckProvince(countryCode, provinceCode) { err = errors.New("country/province not found: " + countryCode + "-" + provinceCode) - return } default: countryCode = subStrings[2] provinceCode = subStrings[1] cityCode = subStrings[0] regionType = CITY - if !checkCity(countryCode, provinceCode, cityCode) { + if !CheckCity(countryCode, provinceCode, cityCode) { err = errors.New("country/province/city not found: " + countryCode + "-" + provinceCode + "-" + cityCode) - return } } - return + + region = Region{ + CountryCode: countryCode, + ProvinceCode: provinceCode, + CityCode: cityCode, + Type: regionType, + } + return region, err } diff --git a/internal/regions/get.go b/internal/regions/get.go index f6a275aa4..b1ef1a406 100644 --- a/internal/regions/get.go +++ b/internal/regions/get.go @@ -1,16 +1,11 @@ package regions -import ( - "challenge16/internal/response" - "fmt" -) - type regionInfo struct { Name string `json:"name"` Code string `json:"code"` } -func GetCountries() response.Response { +func GetCountries() []regionInfo { countries := make([]regionInfo, 0, len(Countries)) for code, country := range Countries { countries = append(countries, regionInfo{ @@ -18,18 +13,17 @@ func GetCountries() response.Response { Code: code, }) } - return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ - "countries": countries, - }) + return countries } -func GetProvincesInCountry(countryCode string) response.Response { - exists := checkCountry(countryCode) - if !exists { - return response.CreateError(404, "COUNTRY_NOT_FOUND", fmt.Errorf("Country not found: %s", countryCode)) +func GetProvincesInCountry(countryCode string) []regionInfo { + if !CheckCountry(countryCode) { + return nil } - country := Countries[countryCode] + if country.Provinces == nil { + return nil + } provinces := make([]regionInfo, 0, len(country.Provinces)) for code, province := range country.Provinces { provinces = append(provinces, regionInfo{ @@ -37,17 +31,13 @@ func GetProvincesInCountry(countryCode string) response.Response { Code: code, }) } - return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ - "provinces": provinces, - }) + return provinces } -func GetCitiesInProvince(countryCode, provinceCode string) response.Response { - exists := checkProvince(countryCode, provinceCode) - if !exists { - return response.CreateError(404, "PROVINCE_NOT_FOUND", fmt.Errorf("Province not found: %s-%s", countryCode, provinceCode)) +func GetCitiesInProvince(countryCode, provinceCode string) []regionInfo { + if !CheckProvince(countryCode, provinceCode) { + return nil } - province := Countries[countryCode].Provinces[provinceCode] cities := make([]regionInfo, 0, len(province.Cities)) for code, name := range province.Cities { @@ -56,7 +46,5 @@ func GetCitiesInProvince(countryCode, provinceCode string) response.Response { Code: code, }) } - return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ - "cities": cities, - }) + return cities } diff --git a/internal/response/create_error.go b/internal/response/create_error.go index 355dc4150..d01d95fc5 100644 --- a/internal/response/create_error.go +++ b/internal/response/create_error.go @@ -1,5 +1,14 @@ package response +import ( + "fmt" + "net/http" +) + +const ( + INVALID_URL_PARAM = "INVALID_URL_PARAM" +) + func CreateError(statusCode int, respcode string, err error) Response { return Response{ HttpStatusCode: statusCode, @@ -17,3 +26,7 @@ func CreateSuccess(statusCode int, respcode string, data interface{}) Response { Data: data, } } + +func InvalidURLParamResponse(param string, err error) Response { + return CreateError(http.StatusBadRequest, INVALID_URL_PARAM, fmt.Errorf("error parsing %v from url: %w", param, err)) +} From db0fb41e87fe08600c8dca848e1ee4a7ba315aa4 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Thu, 6 Mar 2025 17:56:24 +0530 Subject: [PATCH 07/15] ENV parsing, SUMMARY update (#12) --- .env.example | 1 + .gitignore | 3 +- SUMMARY.md | 261 ++++++++++++++++++++++++++++------------- cmd/main.go | 3 +- go.mod | 1 + go.sum | 2 + internal/config/env.go | 36 ++++++ 7 files changed, 221 insertions(+), 86 deletions(-) create mode 100644 .env.example create mode 100644 internal/config/env.go diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..7964059be --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +PORT="4010" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7cdcd1bf8..330ae6dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ main .vscode -__debug_bin* \ No newline at end of file +__debug_bin* +.env \ No newline at end of file diff --git a/SUMMARY.md b/SUMMARY.md index 6204de193..9c3fa7b4b 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -1,36 +1,74 @@ -# Distribution Management System +# ๐Ÿข Distribution Management System This project was developed as part of a machine task for a company interview process. It implements a distribution management system with features for managing distributors and their permissions across different regions. -## Core Features +## ๐ŸŽฏ Core Features -- **Distributor Management** +๐Ÿ“ฆ **Distributor Management** - Add new distributors - Remove existing distributors - - Create sub-distributor relationships - - Region-based validation using cities.csv data + - List all distributors -- **Permission Control** - - Allow distribution rights - - Revoke distribution rights - - Check distribution permission status +๐Ÿ”‘ **Permission Management** + - Allow distribution rights over a region + - Disallow distribution rights over a region + - Check permission status over specific regions (Responses: FULLY_ALLOWED/PARTIALLY_ALLOWED/FULLY_DENIED) + - View distributor-specific permissions (As text(contract) or JSON) + - Contract-based permission management -- **Validation System** +๐ŸŒ **Region Management** + - Hierarchical region structure (Country โ†’ Province โ†’ City) - Region validation against cities.csv database - - Distributor existence verification + - Region code format: "CITYCODE-PROVINCECODE-COUNTRYCODE" -## API Endpoints +## ๐Ÿš€ How to use -### Distributor Management +### Prerequisites +- Go 1.23 or higher +- Git + +### Installation +1. Clone the repository +```bash +git clone https://github.com/AbdulRahimOM/machine_task-challenge2016.git +cd machine_task-challenge2016 +``` + +2. Set up environment variables +```bash +touch .env +echo PORT="4010" >> .env # Or any other port number +``` + +3. Build the project +```bash +make build +``` + +4. Run the server +```bash +./bin/app +``` + +The server will start on `localhost:4010` (or the port specified in the .env file). + +### Region Format +โ€ข Countries: 2-letter code (e.g., "IN", "US") +โ€ข Provinces: 2-letter code + country (e.g., "TN-IN") +โ€ข Cities: City code + province + country (e.g., "CENAI-TN-IN") + +## ๐Ÿ› ๏ธ API Endpoints + +### ๐Ÿ“ฆ Distributor Management #### 1. Add Distributor - **Endpoint**: `POST /distributor` - **Description**: Register a new distributor in the system - **Request Body**: -```json -{ - "distributor":"distributor_name" -} + ```json + { + "distributor": "distributor_name" + } ``` - **Success Response**: 201 Created @@ -40,23 +78,16 @@ This project was developed as part of a machine task for a company interview pro - **Path Parameter**: `distributor` - Name of the distributor - **Success Response**: 200 OK -#### 3. Add Sub-Distributor -- **Endpoint**: `POST /distributor/add-sub` -- **Description**: Create a hierarchical relationship between distributors -- **Request Body**: - ```json -{ - "parent_distributor":"distributor_name", - "sub_distributor":"new_sub_distributor_name"" -} - ``` -- **Success Response**: 201 Created +#### 3. Get Distributors +- **Endpoint**: `GET /distributor` +- **Description**: Retrieve list of all distributors +- **Success Response**: 200 OK with distributors list -### Permission Management +### ๐Ÿ”‘ Permission Management #### 1. Check Distribution Permission - **Endpoint**: `GET /permission/check` -- **Description**: Verify if a distributor has permission for a specific region +- **Description**: Verify distribution permission status for a region - **Query Parameters**: - `distributor`: Distributor name - `region`: Region to check @@ -64,19 +95,24 @@ This project was developed as part of a machine task for a company interview pro #### 2. Allow Distribution - **Endpoint**: `POST /permission/allow` -- **Description**: Grant distribution rights to a distributor +- **Description**: Grant distribution rights for a region - **Request Body**: ```json { "distributor": "distributor_name", - "region": "region_name" (Eg: "KLRAI-TN-IN") + "region": "region_name" // Example: "KLRAI-TN-IN" } ``` - **Success Response**: 200 OK -#### 3. Disallow Distribution +#### 3. Apply Contract +- **Endpoint**: `POST /permission/contract` +- **Description**: Apply distribution contract with permissions +- **Success Response**: 200 OK + +#### 4. Disallow Distribution - **Endpoint**: `POST /permission/disallow` -- **Description**: Revoke distribution rights from a distributor +- **Description**: Revoke distribution rights - **Request Body**: ```json { @@ -86,53 +122,110 @@ This project was developed as part of a machine task for a company interview pro ``` - **Success Response**: 200 OK -## Technical Implementation - -### Implementation Details -- **Clean Architecture Pattern** - - Separation of concerns - - Route handlers in `internal/handler` - - Distribution logics and saving data handled in `internal/data` - - Easy to test and maintain -- **Validation System** - - Region validation using CSV data - - Distributor existence checks - - Permission hierarchy validation -- **Concurrency Handling** - - Prevention of race conditions during concurrent access using read and write locks (sync.RWMutex) - -### Key Components -1. **Route Handlers** (`internal/handler`) - - Handle HTTP requests and responses - - Input validation and sanitization - - Error handling and response formatting - -2. **Business Logic & Data Management** (`internal/data`) - - Distributor management logic - - Permission validation and inheritance - - Region validation against CSV data - - Thread-safe data operations using RWMutex - - Optimized read/write locking for better performance - -## Technical Notes - -- The system performs validation against a predefined list of cities/regions from cities.csv -- Distributor authentication is simplified (no session management) with distributor name passed in request body -- All operations include validation for distributor existence and permission checks -- The system maintains hierarchical relationships between distributors and sub-distributors - -## Future Improvements - -Potential enhancements that could be added: -1. Proper authentication and session management -2. Database persistence for distributor and permission data (restricted by assignment) -3. Caching layer for frequently accessed data (restricted by assignment) -4. More detailed logging and monitoring -5. Rate limiting for API endpoints -6. Testing(unit and integrated) - -## Proposed Model Improvements - -1. **Enhanced Distribution Model** - - Create separate entities for distributors - - Enable multi-directional distribution relationships \ No newline at end of file +#### 5. Get Distributor Permissions +- **Endpoint**: `GET /permission/:distributor` +- **Description**: Retrieve all permissions for a distributor in either JSON or contract text format +- **Path Parameter**: `distributor` - Name of the distributor +- **Query Parameter**: `type` - Response format type ("json" or "text") + - `json`: Returns structured JSON format with permissions + - `text`: Returns formatted contract-like text representation +- **Success Response**: 200 OK with permissions in requested format +- **Response Examples**: + - Text format (`type=text`): + ```text + Permissions for DISTRIBUTOR1 + INCLUDE: IN + INCLUDE: US + INCLUDE: ONATI-SS-ES + EXCLUDE: KA-IN + EXCLUDE: CENAI-TN-IN + ``` + - JSON format (`type=json`): + ```json + { + "status": true, + "resp_code": "SUCCESS", + "data": { + "Distributor": "DISTRIBUTOR1", + "Included": [ + "IN", + "US", + "ONATI-SS-ES" + ], + "Excluded": [ + "KA-IN", + "CENAI-TN-IN" + ] + } + } + ``` + +### ๐ŸŒ Region Management + +#### 1. Get Countries +- **Endpoint**: `GET /regions/countries` +- **Description**: Get list of available countries +- **Success Response**: 200 OK with countries list + +#### 2. Get Provinces +- **Endpoint**: `GET /regions/provinces/:countryCode` +- **Description**: Get provinces in a country +- **Path Parameter**: `countryCode` +- **Success Response**: 200 OK with provinces list + +#### 3. Get Cities +- **Endpoint**: `GET /regions/cities/:countryCode/:provinceCode` +- **Description**: Get cities in a province +- **Path Parameters**: + - `countryCode` + - `provinceCode` +- **Success Response**: 200 OK with cities list + +## ๐Ÿ—๏ธ Technical Implementation + +### ๐ŸŽจ Architecture +- **Clean Architecture Pattern** + - Separation of concerns with handlers and business logic + - RESTful API design + - Modular component structure + +### ๐Ÿ”ง Key Components +1. **Route Handlers** (`internal/handler`) + - HTTP request handling + - Input validation + - Response formatting + - Error handling + +2. **Data Management** + - In-memory data storage + - Thread-safe operations using `sync.RWMutex` + - CSV-based region validation + - Contract validation and processing + +### โš™๏ธ Technical Features +- Region validation against cities.csv +- Concurrent access handling with sync.RWMutex +- Hierarchical permission system +- Contract-based permission management +- Region-based distribution control + +## ๐Ÿ“ Technical Notes +- Thread-safe operations using read-write mutex locks +- CSV-based region validation +- Hierarchical region structure validation +- Contract template validation + +## ๐Ÿš€ Potential Improvements (if assignment is flexible) + +โณ **Contract-expiry** + - When contract expires, cascade expiration to all dependent sub-contracts + - Inheritance on contract, and not on permission + โ†ณ This would be more matching to the real-world scenario, where permissions are time-based and amendable contracts + +๐Ÿ” **Distributor Self-Service Portal** + - Implement secure authentication system + - Enable distributors to manage their own sub-contracts + โ†ณ Create and modify sub-contracts within their permitted scope + โ†ณ Monitor contract status and expiration dates + โ†ณ View inheritance chain and dependencies + โ†ณ Notify them when contract expires \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index c03728ce6..13ccc7efe 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "challenge16/internal/config" "challenge16/internal/handler" "fmt" @@ -41,7 +42,7 @@ func main() { } } - err := app.Listen(fmt.Sprintf(":4010")) + err := app.Listen(fmt.Sprintf(":%s", config.Port)) if err != nil { panic("Couldn't start the server. Error: " + err.Error()) } diff --git a/go.mod b/go.mod index c37680c66..5fdc7b5ff 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.2 require ( github.com/go-playground/validator/v10 v10.25.0 github.com/gofiber/fiber/v2 v2.52.6 + github.com/joho/godotenv v1.5.1 ) require ( diff --git a/go.sum b/go.sum index 2cdbff2e0..341fb6cb4 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27X github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 000000000..519700dfd --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,36 @@ +package config + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" +) + +const ( + defaultPort = "4010" +) + +var ( + Port string +) + +func init() { + loadEnv() +} + +func loadEnv() { + fmt.Println("Loading .env file...") + //parse .env file + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file. err", err) + } + + Port = os.Getenv("PORT") + if Port == "" { + Port = defaultPort + } + +} From c9e8d5e4946fc009baff6cb4db007fb912e43ecb Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Thu, 6 Mar 2025 18:20:20 +0530 Subject: [PATCH 08/15] Rate limiting (#13) --- .env.example | 3 +- Assignment.md | 41 +++++++ README.md | 244 ++++++++++++++++++++++++++++++++++++----- cmd/main.go | 6 + go.mod | 2 + go.sum | 4 + internal/config/env.go | 13 ++- 7 files changed, 286 insertions(+), 27 deletions(-) create mode 100644 Assignment.md diff --git a/.env.example b/.env.example index 7964059be..a374482ff 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -PORT="4010" \ No newline at end of file +PORT=4010 +RATE_LIMIT=60 diff --git a/Assignment.md b/Assignment.md new file mode 100644 index 000000000..f1c342f65 --- /dev/null +++ b/Assignment.md @@ -0,0 +1,41 @@ +# Real Image Challenge 2016 + +In the cinema business, a feature film is usually provided to a regional distributor based on a contract for exhibition in a particular geographical territory. + +Each authorization is specified by a combination of included and excluded regions. For example, a distributor might be authorzied in the following manner: +``` +Permissions for DISTRIBUTOR1 +INCLUDE: INDIA +INCLUDE: UNITEDSTATES +EXCLUDE: KARNATAKA-INDIA +EXCLUDE: CHENNAI-TAMILNADU-INDIA +``` +This allows `DISTRIBUTOR1` to distribute in any city inside the United States and India, *except* cities in the state of Karnataka (in India) and the city of Chennai (in Tamil Nadu, India). + +At this point, asking your program if `DISTRIBUTOR1` has permission to distribute in `CHICAGO-ILLINOIS-UNITEDSTATES` should get `YES` as the answer, and asking if distribution can happen in `CHENNAI-TAMILNADU-INDIA` should of course be `NO`. Asking if distribution is possible in `BANGALORE-KARNATAKA-INDIA` should also be `NO`, because the whole state of Karnataka has been excluded. + +Sometimes, a distributor might split the work of distribution amount smaller sub-distiributors inside their authorized geographies. For instance, `DISTRIBUTOR1` might assign the following permissions to `DISTRIBUTOR2`: + +``` +Permissions for DISTRIBUTOR2 < DISTRIBUTOR1 +INCLUDE: INDIA +EXCLUDE: TAMILNADU-INDIA +``` +Now, `DISTRIBUTOR2` can distribute the movie anywhere in `INDIA`, except inside `TAMILNADU-INDIA` and `KARNATAKA-INDIA` - `DISTRIBUTOR2`'s permissions are always a subset of `DISTRIBUTOR1`'s permissions. It's impossible/invalid for `DISTRIBUTOR2` to have `INCLUDE: CHINA`, for example, because `DISTRIBUTOR1` isn't authorized to do that in the first place. + +If `DISTRIBUTOR2` authorizes `DISTRIBUTOR3` to handle just the city of Hubli, Karnataka, India, for example: +``` +Permissions for DISTRIBUTOR3 < DISTRIBUTOR2 < DISTRIBUTOR1 +INCLUDE: HUBLI-KARNATAKA-INDIA +``` +Again, `DISTRIBUTOR2` cannot authorize `DISTRIBUTOR3` with a region that they themselves do not have access to. + +We've provided a CSV with the list of all countries, states and cities in the world that we know of - please use the data mentioned there for this program. *The codes you see there may be different from what you see here, so please always use the codes in the CSV*. This Readme is only an example. + +Write a program in any language you want (If you're here from Gophercon, use Go :D) that does this. Feel free to make your own input and output format / command line tool / GUI / Webservice / whatever you want. Feel free to hold the dataset in whatever structure you want, but try not to use external databases - as far as possible stick to your langauage without bringing in MySQL/Postgres/MongoDB/Redis/Etc. + +To submit a solution, fork this repo and send a Pull Request on Github. + +For any questions or clarifications, raise an issue on this repo and we'll answer your questions as fast as we can. + + diff --git a/README.md b/README.md index f1c342f65..1c4f4ecf7 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,235 @@ -# Real Image Challenge 2016 +# ๐Ÿข Distribution Management System -In the cinema business, a feature film is usually provided to a regional distributor based on a contract for exhibition in a particular geographical territory. +This project was developed as part of a machine task for a company interview process. It implements a distribution management system with features for managing distributors and their permissions across different regions. -Each authorization is specified by a combination of included and excluded regions. For example, a distributor might be authorzied in the following manner: -``` -Permissions for DISTRIBUTOR1 -INCLUDE: INDIA -INCLUDE: UNITEDSTATES -EXCLUDE: KARNATAKA-INDIA -EXCLUDE: CHENNAI-TAMILNADU-INDIA -``` -This allows `DISTRIBUTOR1` to distribute in any city inside the United States and India, *except* cities in the state of Karnataka (in India) and the city of Chennai (in Tamil Nadu, India). +## ๐ŸŽฏ Core Features + +๐Ÿ“ฆ **Distributor Management** + - Add new distributors + - Remove existing distributors + - List all distributors + +๐Ÿ”‘ **Permission Management** + - Allow distribution rights over a region + - Disallow distribution rights over a region + - Check permission status over specific regions (Responses: FULLY_ALLOWED/PARTIALLY_ALLOWED/FULLY_DENIED) + - View distributor-specific permissions (As text(contract) or JSON) + - Contract-based permission management -At this point, asking your program if `DISTRIBUTOR1` has permission to distribute in `CHICAGO-ILLINOIS-UNITEDSTATES` should get `YES` as the answer, and asking if distribution can happen in `CHENNAI-TAMILNADU-INDIA` should of course be `NO`. Asking if distribution is possible in `BANGALORE-KARNATAKA-INDIA` should also be `NO`, because the whole state of Karnataka has been excluded. +๐ŸŒ **Region Management** + - Hierarchical region structure (Country โ†’ Province โ†’ City) + - Region validation against cities.csv database + - Region code format: "CITYCODE-PROVINCECODE-COUNTRYCODE" -Sometimes, a distributor might split the work of distribution amount smaller sub-distiributors inside their authorized geographies. For instance, `DISTRIBUTOR1` might assign the following permissions to `DISTRIBUTOR2`: +## ๐Ÿš€ How to use +### Prerequisites +- Go 1.23 or higher +- Git + +### Installation +1. Clone the repository +```bash +git clone https://github.com/AbdulRahimOM/machine_task-challenge2016.git +cd machine_task-challenge2016 ``` -Permissions for DISTRIBUTOR2 < DISTRIBUTOR1 -INCLUDE: INDIA -EXCLUDE: TAMILNADU-INDIA + +2. Set up environment variables +```bash +touch .env +echo PORT="4010" >> .env # Or any other port number ``` -Now, `DISTRIBUTOR2` can distribute the movie anywhere in `INDIA`, except inside `TAMILNADU-INDIA` and `KARNATAKA-INDIA` - `DISTRIBUTOR2`'s permissions are always a subset of `DISTRIBUTOR1`'s permissions. It's impossible/invalid for `DISTRIBUTOR2` to have `INCLUDE: CHINA`, for example, because `DISTRIBUTOR1` isn't authorized to do that in the first place. -If `DISTRIBUTOR2` authorizes `DISTRIBUTOR3` to handle just the city of Hubli, Karnataka, India, for example: +3. Build the project +```bash +make build ``` -Permissions for DISTRIBUTOR3 < DISTRIBUTOR2 < DISTRIBUTOR1 -INCLUDE: HUBLI-KARNATAKA-INDIA + +4. Run the server +```bash +./bin/app ``` -Again, `DISTRIBUTOR2` cannot authorize `DISTRIBUTOR3` with a region that they themselves do not have access to. -We've provided a CSV with the list of all countries, states and cities in the world that we know of - please use the data mentioned there for this program. *The codes you see there may be different from what you see here, so please always use the codes in the CSV*. This Readme is only an example. +The server will start on `localhost:4010` (or the port specified in the .env file). + +### Region Format +โ€ข Countries: 2-letter code (e.g., "IN", "US") +โ€ข Provinces: 2-letter code + country (e.g., "TN-IN") +โ€ข Cities: City code + province + country (e.g., "CENAI-TN-IN") + +## ๐Ÿ› ๏ธ API Endpoints + +### ๐Ÿ“ฆ Distributor Management + +#### 1. Add Distributor +- **Endpoint**: `POST /distributor` +- **Description**: Register a new distributor in the system +- **Request Body**: + ```json + { + "distributor": "distributor_name" + } + ``` +- **Success Response**: 201 Created + +#### 2. Remove Distributor +- **Endpoint**: `DELETE /distributor/:distributor` +- **Description**: Remove an existing distributor from the system +- **Path Parameter**: `distributor` - Name of the distributor +- **Success Response**: 200 OK + +#### 3. Get Distributors +- **Endpoint**: `GET /distributor` +- **Description**: Retrieve list of all distributors +- **Success Response**: 200 OK with distributors list + +### ๐Ÿ”‘ Permission Management + +#### 1. Check Distribution Permission +- **Endpoint**: `GET /permission/check` +- **Description**: Verify distribution permission status for a region +- **Query Parameters**: + - `distributor`: Distributor name + - `region`: Region to check +- **Success Response**: 200 OK with permission status + +#### 2. Allow Distribution +- **Endpoint**: `POST /permission/allow` +- **Description**: Grant distribution rights for a region +- **Request Body**: + ```json + { + "distributor": "distributor_name", + "region": "region_name" // Example: "KLRAI-TN-IN" + } + ``` +- **Success Response**: 200 OK + +#### 3. Apply Contract +- **Endpoint**: `POST /permission/contract` +- **Description**: Apply distribution contract with permissions +- **Success Response**: 200 OK + +#### 4. Disallow Distribution +- **Endpoint**: `POST /permission/disallow` +- **Description**: Revoke distribution rights +- **Request Body**: + ```json + { + "distributor": "distributor_name", + "region": "region_name" + } + ``` +- **Success Response**: 200 OK + +#### 5. Get Distributor Permissions +- **Endpoint**: `GET /permission/:distributor` +- **Description**: Retrieve all permissions for a distributor in either JSON or contract text format +- **Path Parameter**: `distributor` - Name of the distributor +- **Query Parameter**: `type` - Response format type ("json" or "text") + - `json`: Returns structured JSON format with permissions + - `text`: Returns formatted contract-like text representation +- **Success Response**: 200 OK with permissions in requested format +- **Response Examples**: + - Text format (`type=text`): + ```text + Permissions for DISTRIBUTOR1 + INCLUDE: IN + INCLUDE: US + INCLUDE: ONATI-SS-ES + EXCLUDE: KA-IN + EXCLUDE: CENAI-TN-IN + ``` + - JSON format (`type=json`): + ```json + { + "status": true, + "resp_code": "SUCCESS", + "data": { + "Distributor": "DISTRIBUTOR1", + "Included": [ + "IN", + "US", + "ONATI-SS-ES" + ], + "Excluded": [ + "KA-IN", + "CENAI-TN-IN" + ] + } + } + ``` + +### ๐ŸŒ Region Management + +#### 1. Get Countries +- **Endpoint**: `GET /regions/countries` +- **Description**: Get list of available countries +- **Success Response**: 200 OK with countries list + +#### 2. Get Provinces +- **Endpoint**: `GET /regions/provinces/:countryCode` +- **Description**: Get provinces in a country +- **Path Parameter**: `countryCode` +- **Success Response**: 200 OK with provinces list + +#### 3. Get Cities +- **Endpoint**: `GET /regions/cities/:countryCode/:provinceCode` +- **Description**: Get cities in a province +- **Path Parameters**: + - `countryCode` + - `provinceCode` +- **Success Response**: 200 OK with cities list + +## ๐Ÿ—๏ธ Technical Implementation + +### ๐ŸŽจ Architecture +- **Clean Architecture Pattern** + - Separation of concerns with handlers and business logic + - RESTful API design + - Modular component structure + +### ๐Ÿ›ก๏ธ Security Enhancements +- **Rate Limiting**: Prevents excessive API requests to safeguard system resources +- **Data Validation & Sanitization**: Ensures proper input handling to avoid malicious data + +### ๐Ÿ”ง Key Components +1. **Route Handlers** (`internal/handler`) + - HTTP request handling + - Input validation + - Response formatting + - Error handling -Write a program in any language you want (If you're here from Gophercon, use Go :D) that does this. Feel free to make your own input and output format / command line tool / GUI / Webservice / whatever you want. Feel free to hold the dataset in whatever structure you want, but try not to use external databases - as far as possible stick to your langauage without bringing in MySQL/Postgres/MongoDB/Redis/Etc. +2. **Data Management** + - In-memory data storage + - Thread-safe operations using `sync.RWMutex` + - CSV-based region validation + - Contract validation and processing + +### โš™๏ธ Technical Features +- Region validation against cities.csv +- Concurrent access handling with sync.RWMutex +- Hierarchical permission system +- Contract-based permission management +- Region-based distribution control -To submit a solution, fork this repo and send a Pull Request on Github. +## ๐Ÿ“ Technical Notes +- Thread-safe operations using read-write mutex locks +- CSV-based region validation +- Hierarchical region structure validation +- Contract template validation -For any questions or clarifications, raise an issue on this repo and we'll answer your questions as fast as we can. +## ๐Ÿš€ Potential Improvements (if assignment is flexible) +โณ **Contract-expiry** + - When contract expires, cascade expiration to all dependent sub-contracts + - Inheritance on contract, and not on permission + โ†ณ This would be more matching to the real-world scenario, where permissions are time-based and amendable contracts +๐Ÿ” **Distributor Self-Service Portal** + - Implement secure authentication system + - Enable distributors to manage their own sub-contracts + โ†ณ Create and modify sub-contracts within their permitted scope + โ†ณ Monitor contract status and expiration dates + โ†ณ View inheritance chain and dependencies + โ†ณ Notify them when contract expires \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 13ccc7efe..177a0516e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,8 +4,10 @@ import ( "challenge16/internal/config" "challenge16/internal/handler" "fmt" + "time" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" "github.com/gofiber/fiber/v2/middleware/logger" ) @@ -13,6 +15,10 @@ func main() { app := fiber.New() app.Use(logger.New()) + app.Use(limiter.New(limiter.Config{ + Max: config.RateLimit, + Expiration: 1 * time.Minute, + })) handler := handler.NewHandler() diff --git a/go.mod b/go.mod index 5fdc7b5ff..dd2a681c1 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,9 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect diff --git a/go.sum b/go.sum index 341fb6cb4..22b2e2e9e 100644 --- a/go.sum +++ b/go.sum @@ -29,12 +29,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= diff --git a/internal/config/env.go b/internal/config/env.go index 519700dfd..50f4c54e1 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "strconv" "github.com/joho/godotenv" ) @@ -13,7 +14,8 @@ const ( ) var ( - Port string + Port string + RateLimit int ) func init() { @@ -33,4 +35,13 @@ func loadEnv() { Port = defaultPort } + RateLimit,err = strconv.Atoi(os.Getenv("RATE_LIMIT")) + if err != nil { + if os.Getenv("RATE_LIMIT") == "" { + RateLimit = 60 + } else { + log.Fatal("Error loading RATE_LIMIT from .env file. err", err) + } + } + } From f14dde984793261c294c38263e7a2cd93e028e5e Mon Sep 17 00:00:00 2001 From: AbdulRahimOM Date: Thu, 6 Mar 2025 18:25:25 +0530 Subject: [PATCH 09/15] README edit --- README.md | 128 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 66 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 1c4f4ecf7..c1ff67e6f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,71 @@ This project was developed as part of a machine task for a company interview pro - Hierarchical region structure (Country โ†’ Province โ†’ City) - Region validation against cities.csv database - Region code format: "CITYCODE-PROVINCECODE-COUNTRYCODE" + +### Region Format +- Countries: 2-letter code (e.g., "IN", "US") +- Provinces: 2-letter code + country (e.g., "TN-IN") +- Cities: City code + province + country (e.g., "CENAI-TN-IN") + +### ๐ŸŒ Region Management + +#### 1. Get Countries +- **Endpoint**: `GET /regions/countries` +- **Description**: Get list of available countries +- **Success Response**: 200 OK with countries list + +#### 2. Get Provinces +- **Endpoint**: `GET /regions/provinces/:countryCode` +- **Description**: Get provinces in a country +- **Path Parameter**: `countryCode` +- **Success Response**: 200 OK with provinces list + +#### 3. Get Cities +- **Endpoint**: `GET /regions/cities/:countryCode/:provinceCode` +- **Description**: Get cities in a province +- **Path Parameters**: + - `countryCode` + - `provinceCode` +- **Success Response**: 200 OK with cities list + +## ๐Ÿ—๏ธ Technical Implementation + +### ๐ŸŽจ Architecture +- **Clean Architecture Pattern** + - Separation of concerns with handlers and business logic + - RESTful API design + - Modular component structure + +### ๐Ÿ›ก๏ธ Security Enhancements +- **Rate Limiting**: Prevents excessive API requests to safeguard system resources +- **Data Validation & Sanitization**: Ensures proper input handling to avoid malicious data + +### ๐Ÿ”ง Key Components +1. **Route Handlers** (`internal/handler`) + - HTTP request handling + - Input validation + - Response formatting + - Error handling + +2. **Data Management** + - In-memory data storage + - Thread-safe operations using `sync.RWMutex` + - CSV-based region validation + - Contract validation and processing + +### โš™๏ธ Technical Features +- Region validation against cities.csv +- Concurrent access handling with sync.RWMutex +- Hierarchical permission system +- Contract-based permission management +- Region-based distribution control + +## ๐Ÿ“ Technical Notes +- Thread-safe operations using read-write mutex locks +- CSV-based region validation +- Hierarchical region structure validation +- Contract template validation + ## ๐Ÿš€ How to use @@ -38,6 +103,7 @@ cd machine_task-challenge2016 ```bash touch .env echo PORT="4010" >> .env # Or any other port number +echo RATE_LIMIT="60" >> .env # Requests per minute limit ``` 3. Build the project @@ -52,10 +118,6 @@ make build The server will start on `localhost:4010` (or the port specified in the .env file). -### Region Format -โ€ข Countries: 2-letter code (e.g., "IN", "US") -โ€ข Provinces: 2-letter code + country (e.g., "TN-IN") -โ€ข Cities: City code + province + country (e.g., "CENAI-TN-IN") ## ๐Ÿ› ๏ธ API Endpoints @@ -160,64 +222,6 @@ The server will start on `localhost:4010` (or the port specified in the .env fil } ``` -### ๐ŸŒ Region Management - -#### 1. Get Countries -- **Endpoint**: `GET /regions/countries` -- **Description**: Get list of available countries -- **Success Response**: 200 OK with countries list - -#### 2. Get Provinces -- **Endpoint**: `GET /regions/provinces/:countryCode` -- **Description**: Get provinces in a country -- **Path Parameter**: `countryCode` -- **Success Response**: 200 OK with provinces list - -#### 3. Get Cities -- **Endpoint**: `GET /regions/cities/:countryCode/:provinceCode` -- **Description**: Get cities in a province -- **Path Parameters**: - - `countryCode` - - `provinceCode` -- **Success Response**: 200 OK with cities list - -## ๐Ÿ—๏ธ Technical Implementation - -### ๐ŸŽจ Architecture -- **Clean Architecture Pattern** - - Separation of concerns with handlers and business logic - - RESTful API design - - Modular component structure - -### ๐Ÿ›ก๏ธ Security Enhancements -- **Rate Limiting**: Prevents excessive API requests to safeguard system resources -- **Data Validation & Sanitization**: Ensures proper input handling to avoid malicious data - -### ๐Ÿ”ง Key Components -1. **Route Handlers** (`internal/handler`) - - HTTP request handling - - Input validation - - Response formatting - - Error handling - -2. **Data Management** - - In-memory data storage - - Thread-safe operations using `sync.RWMutex` - - CSV-based region validation - - Contract validation and processing - -### โš™๏ธ Technical Features -- Region validation against cities.csv -- Concurrent access handling with sync.RWMutex -- Hierarchical permission system -- Contract-based permission management -- Region-based distribution control - -## ๐Ÿ“ Technical Notes -- Thread-safe operations using read-write mutex locks -- CSV-based region validation -- Hierarchical region structure validation -- Contract template validation ## ๐Ÿš€ Potential Improvements (if assignment is flexible) From e4fa474ee6b2e8de102b9168d4d9a990b071ee3c Mon Sep 17 00:00:00 2001 From: AbdulRahimOM Date: Thu, 6 Mar 2025 19:37:02 +0530 Subject: [PATCH 10/15] Misc restructure --- Makefile | 5 +- SUMMARY.md | 231 ------------------ internal/handler/distributer.go | 2 +- internal/handler/permission.go | 2 +- .../validation}/handle_request.go | 0 .../validation}/validation.go | 0 6 files changed, 6 insertions(+), 234 deletions(-) delete mode 100644 SUMMARY.md rename {validation => utils/validation}/handle_request.go (100%) rename {validation => utils/validation}/validation.go (100%) diff --git a/Makefile b/Makefile index 0a71248c8..de792bd17 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,5 @@ running: - CompileDaemon -build="go build -o ./cmd/main ./cmd" -command=./cmd/main \ No newline at end of file + CompileDaemon -build="go build -o ./cmd/main ./cmd" -command=./cmd/main + +run: + go run ./cmd/main.go \ No newline at end of file diff --git a/SUMMARY.md b/SUMMARY.md deleted file mode 100644 index 9c3fa7b4b..000000000 --- a/SUMMARY.md +++ /dev/null @@ -1,231 +0,0 @@ -# ๐Ÿข Distribution Management System - -This project was developed as part of a machine task for a company interview process. It implements a distribution management system with features for managing distributors and their permissions across different regions. - -## ๐ŸŽฏ Core Features - -๐Ÿ“ฆ **Distributor Management** - - Add new distributors - - Remove existing distributors - - List all distributors - -๐Ÿ”‘ **Permission Management** - - Allow distribution rights over a region - - Disallow distribution rights over a region - - Check permission status over specific regions (Responses: FULLY_ALLOWED/PARTIALLY_ALLOWED/FULLY_DENIED) - - View distributor-specific permissions (As text(contract) or JSON) - - Contract-based permission management - -๐ŸŒ **Region Management** - - Hierarchical region structure (Country โ†’ Province โ†’ City) - - Region validation against cities.csv database - - Region code format: "CITYCODE-PROVINCECODE-COUNTRYCODE" - -## ๐Ÿš€ How to use - -### Prerequisites -- Go 1.23 or higher -- Git - -### Installation -1. Clone the repository -```bash -git clone https://github.com/AbdulRahimOM/machine_task-challenge2016.git -cd machine_task-challenge2016 -``` - -2. Set up environment variables -```bash -touch .env -echo PORT="4010" >> .env # Or any other port number -``` - -3. Build the project -```bash -make build -``` - -4. Run the server -```bash -./bin/app -``` - -The server will start on `localhost:4010` (or the port specified in the .env file). - -### Region Format -โ€ข Countries: 2-letter code (e.g., "IN", "US") -โ€ข Provinces: 2-letter code + country (e.g., "TN-IN") -โ€ข Cities: City code + province + country (e.g., "CENAI-TN-IN") - -## ๐Ÿ› ๏ธ API Endpoints - -### ๐Ÿ“ฆ Distributor Management - -#### 1. Add Distributor -- **Endpoint**: `POST /distributor` -- **Description**: Register a new distributor in the system -- **Request Body**: - ```json - { - "distributor": "distributor_name" - } - ``` -- **Success Response**: 201 Created - -#### 2. Remove Distributor -- **Endpoint**: `DELETE /distributor/:distributor` -- **Description**: Remove an existing distributor from the system -- **Path Parameter**: `distributor` - Name of the distributor -- **Success Response**: 200 OK - -#### 3. Get Distributors -- **Endpoint**: `GET /distributor` -- **Description**: Retrieve list of all distributors -- **Success Response**: 200 OK with distributors list - -### ๐Ÿ”‘ Permission Management - -#### 1. Check Distribution Permission -- **Endpoint**: `GET /permission/check` -- **Description**: Verify distribution permission status for a region -- **Query Parameters**: - - `distributor`: Distributor name - - `region`: Region to check -- **Success Response**: 200 OK with permission status - -#### 2. Allow Distribution -- **Endpoint**: `POST /permission/allow` -- **Description**: Grant distribution rights for a region -- **Request Body**: - ```json - { - "distributor": "distributor_name", - "region": "region_name" // Example: "KLRAI-TN-IN" - } - ``` -- **Success Response**: 200 OK - -#### 3. Apply Contract -- **Endpoint**: `POST /permission/contract` -- **Description**: Apply distribution contract with permissions -- **Success Response**: 200 OK - -#### 4. Disallow Distribution -- **Endpoint**: `POST /permission/disallow` -- **Description**: Revoke distribution rights -- **Request Body**: - ```json - { - "distributor": "distributor_name", - "region": "region_name" - } - ``` -- **Success Response**: 200 OK - -#### 5. Get Distributor Permissions -- **Endpoint**: `GET /permission/:distributor` -- **Description**: Retrieve all permissions for a distributor in either JSON or contract text format -- **Path Parameter**: `distributor` - Name of the distributor -- **Query Parameter**: `type` - Response format type ("json" or "text") - - `json`: Returns structured JSON format with permissions - - `text`: Returns formatted contract-like text representation -- **Success Response**: 200 OK with permissions in requested format -- **Response Examples**: - - Text format (`type=text`): - ```text - Permissions for DISTRIBUTOR1 - INCLUDE: IN - INCLUDE: US - INCLUDE: ONATI-SS-ES - EXCLUDE: KA-IN - EXCLUDE: CENAI-TN-IN - ``` - - JSON format (`type=json`): - ```json - { - "status": true, - "resp_code": "SUCCESS", - "data": { - "Distributor": "DISTRIBUTOR1", - "Included": [ - "IN", - "US", - "ONATI-SS-ES" - ], - "Excluded": [ - "KA-IN", - "CENAI-TN-IN" - ] - } - } - ``` - -### ๐ŸŒ Region Management - -#### 1. Get Countries -- **Endpoint**: `GET /regions/countries` -- **Description**: Get list of available countries -- **Success Response**: 200 OK with countries list - -#### 2. Get Provinces -- **Endpoint**: `GET /regions/provinces/:countryCode` -- **Description**: Get provinces in a country -- **Path Parameter**: `countryCode` -- **Success Response**: 200 OK with provinces list - -#### 3. Get Cities -- **Endpoint**: `GET /regions/cities/:countryCode/:provinceCode` -- **Description**: Get cities in a province -- **Path Parameters**: - - `countryCode` - - `provinceCode` -- **Success Response**: 200 OK with cities list - -## ๐Ÿ—๏ธ Technical Implementation - -### ๐ŸŽจ Architecture -- **Clean Architecture Pattern** - - Separation of concerns with handlers and business logic - - RESTful API design - - Modular component structure - -### ๐Ÿ”ง Key Components -1. **Route Handlers** (`internal/handler`) - - HTTP request handling - - Input validation - - Response formatting - - Error handling - -2. **Data Management** - - In-memory data storage - - Thread-safe operations using `sync.RWMutex` - - CSV-based region validation - - Contract validation and processing - -### โš™๏ธ Technical Features -- Region validation against cities.csv -- Concurrent access handling with sync.RWMutex -- Hierarchical permission system -- Contract-based permission management -- Region-based distribution control - -## ๐Ÿ“ Technical Notes -- Thread-safe operations using read-write mutex locks -- CSV-based region validation -- Hierarchical region structure validation -- Contract template validation - -## ๐Ÿš€ Potential Improvements (if assignment is flexible) - -โณ **Contract-expiry** - - When contract expires, cascade expiration to all dependent sub-contracts - - Inheritance on contract, and not on permission - โ†ณ This would be more matching to the real-world scenario, where permissions are time-based and amendable contracts - -๐Ÿ” **Distributor Self-Service Portal** - - Implement secure authentication system - - Enable distributors to manage their own sub-contracts - โ†ณ Create and modify sub-contracts within their permitted scope - โ†ณ Monitor contract status and expiration dates - โ†ณ View inheritance chain and dependencies - โ†ณ Notify them when contract expires \ No newline at end of file diff --git a/internal/handler/distributer.go b/internal/handler/distributer.go index 898ebaf47..fc35f2bf3 100644 --- a/internal/handler/distributer.go +++ b/internal/handler/distributer.go @@ -2,7 +2,7 @@ package handler import ( "challenge16/internal/response" - "challenge16/validation" + "challenge16/utils/validation" "errors" "github.com/gofiber/fiber/v2" diff --git a/internal/handler/permission.go b/internal/handler/permission.go index 793c6fa71..52675df74 100644 --- a/internal/handler/permission.go +++ b/internal/handler/permission.go @@ -3,7 +3,7 @@ package handler import ( "challenge16/internal/dto" "challenge16/internal/response" - "challenge16/validation" + "challenge16/utils/validation" "errors" "strings" diff --git a/validation/handle_request.go b/utils/validation/handle_request.go similarity index 100% rename from validation/handle_request.go rename to utils/validation/handle_request.go diff --git a/validation/validation.go b/utils/validation/validation.go similarity index 100% rename from validation/validation.go rename to utils/validation/validation.go From 0da68e96bf0a853f666a7fe332c0fa05d7e25116 Mon Sep 17 00:00:00 2001 From: AbdulRahimOM Date: Fri, 7 Mar 2025 00:21:02 +0530 Subject: [PATCH 11/15] Map check for nil --- internal/data/process_contract.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/data/process_contract.go b/internal/data/process_contract.go index 4130d7929..df2855a8b 100644 --- a/internal/data/process_contract.go +++ b/internal/data/process_contract.go @@ -104,6 +104,9 @@ func (db *DataBank) filterContractPermissionsBasedOnParentPermissions(contract d //provincial level exclusion for country := range parentPermission.excludedProvinces { if contractPermissions.IncludedCountries[country] { + if _, exists := contractPermissions.ExcludedProvinces[country]; !exists { + contractPermissions.ExcludedProvinces[country] = make(map[string]bool) + } mergeMapIntoMap(contractPermissions.ExcludedProvinces[country], parentPermission.excludedProvinces[country]) continue } From 1805adc49a2af017ada8034c6db7961d9c23cd74 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 7 Mar 2025 18:35:53 +0530 Subject: [PATCH 12/15] Initialisation restructured(init funcs removed) (#14) * For ease of testing * init functions removed, and direct call from main.go implemented instead --- cmd/main.go | 51 +++++++---------------------- internal/config/env.go | 10 +++--- internal/data/data.go | 9 ++---- internal/data/process_contract.go | 3 +- internal/dto/get_permissions.go | 7 ++++ internal/handler/permission.go | 8 +++++ internal/regions/check.go | 8 +++-- internal/regions/init.go | 10 ------ internal/server/server.go | 53 +++++++++++++++++++++++++++++++ 9 files changed, 94 insertions(+), 65 deletions(-) create mode 100644 internal/dto/get_permissions.go create mode 100644 internal/server/server.go diff --git a/cmd/main.go b/cmd/main.go index 177a0516e..918265784 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,54 +2,27 @@ package main import ( "challenge16/internal/config" - "challenge16/internal/handler" + "challenge16/internal/regions" + "challenge16/internal/server" "fmt" - "time" +) - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/limiter" - "github.com/gofiber/fiber/v2/middleware/logger" +const ( + csvFile = "cities.csv" + envPath = ".env" ) func main() { + //initialize the region data + regions.LoadDataIntoMap(csvFile) - app := fiber.New() - app.Use(logger.New()) - app.Use(limiter.New(limiter.Config{ - Max: config.RateLimit, - Expiration: 1 * time.Minute, - })) - - handler := handler.NewHandler() - - //initialize the routes - { - distributor := app.Group("/distributor") - { - distributor.Post("/", handler.AddDistributor) - distributor.Delete("/:distributor", handler.RemoveDistributor) - distributor.Get("/", handler.GetDistributors) - } + //initialize the environment configuration + config.LoadEnv(envPath) - permission := app.Group("/permission") - { - permission.Get("/check", handler.CheckIfDistributionIsAllowed) - permission.Post("/allow", handler.AllowDistribution) - permission.Post("/contract", handler.ApplyContract) - permission.Post("/disallow", handler.DisallowDistribution) - permission.Get("/:distributor", handler.GetDistributorPermissions) - } - - regions := app.Group("/regions") - { - regions.Get("/countries", handler.GetCountries) - regions.Get("/provinces/:countryCode", handler.GetProvincesInCountry) - regions.Get("/cities/:countryCode/:provinceCode", handler.GetCitiesInProvince) - } - } + app := server.NewServer() err := app.Listen(fmt.Sprintf(":%s", config.Port)) if err != nil { panic("Couldn't start the server. Error: " + err.Error()) } -} +} \ No newline at end of file diff --git a/internal/config/env.go b/internal/config/env.go index 50f4c54e1..e8a0a7d47 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -18,14 +18,14 @@ var ( RateLimit int ) -func init() { - loadEnv() -} +// func init() { +// loadEnv() +// } -func loadEnv() { +func LoadEnv(path string) { fmt.Println("Loading .env file...") //parse .env file - err := godotenv.Load() + err := godotenv.Load(path) if err != nil { log.Fatal("Error loading .env file. err", err) } diff --git a/internal/data/data.go b/internal/data/data.go index 43d5566b7..1daea07f8 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -1,6 +1,7 @@ package data import ( + "challenge16/internal/dto" "challenge16/internal/regions" "challenge16/internal/response" "errors" @@ -556,15 +557,11 @@ func (db *DataBank) GetDistributorPermissionAsJSON(distributor string) response. } } - resp := struct { - Distributor string - Included []string - Excluded []string - }{ + data:=dto.GetPermissionsData{ Distributor: distributor, Included: inclusions, Excluded: exclusions, } - return response.CreateSuccess(200, "SUCCESS", resp) + return response.CreateSuccess(200, "SUCCESS", data) } diff --git a/internal/data/process_contract.go b/internal/data/process_contract.go index df2855a8b..e511f57ac 100644 --- a/internal/data/process_contract.go +++ b/internal/data/process_contract.go @@ -198,7 +198,6 @@ func (db *DataBank) filterContractPermissionsBasedOnParentPermissions(contract d } func validateContract(contract dto.Contract) error { - // Validate the contract here //if a region is included, sub regions should only be of 'excluded' type for country := range contract.IncludedCountries { if _, exists := contract.IncludedProvinces[country]; exists && len(contract.IncludedProvinces[country]) > 0 { @@ -463,7 +462,7 @@ func (db *DataBank) ApplyContract(contract dto.Contract) response.Response { if contract.ParentDistributor != nil { if !db.distributorExists(*contract.ParentDistributor) { - return response.CreateError(400, "PARENT_DISTRIBUTOR_NOT_FOUND", fmt.Errorf("parent distributor %s not found", *contract.ParentDistributor)) + return response.CreateError(404, "PARENT_DISTRIBUTOR_NOT_FOUND", fmt.Errorf("parent distributor %s not found", *contract.ParentDistributor)) } db.filterContractPermissionsBasedOnParentPermissions(contract) } diff --git a/internal/dto/get_permissions.go b/internal/dto/get_permissions.go new file mode 100644 index 000000000..ddf9cf7a2 --- /dev/null +++ b/internal/dto/get_permissions.go @@ -0,0 +1,7 @@ +package dto + +type GetPermissionsData struct { + Distributor string `json:"distributor"` + Included []string `json:"included"` + Excluded []string `json:"excluded"` +} diff --git a/internal/handler/permission.go b/internal/handler/permission.go index 52675df74..0e6e4376d 100644 --- a/internal/handler/permission.go +++ b/internal/handler/permission.go @@ -2,6 +2,7 @@ package handler import ( "challenge16/internal/dto" + "challenge16/internal/regions" "challenge16/internal/response" "challenge16/utils/validation" "errors" @@ -58,6 +59,13 @@ func (h *handler) ApplyContract(c *fiber.Ctx) error { contractText := string(c.Body()) contract, err := getContractData(contractText) if err != nil { + if strings.HasPrefix(err.Error(), regions.InvalidRegionPrefix) { + return response.Response{ + HttpStatusCode: 404, + ResponseCode: "REGION_NOT_FOUND", + Error: err, + }.WriteToJSON(c) + } return response.Response{ HttpStatusCode: 400, ResponseCode: "INVALID_CONTRACT", diff --git a/internal/regions/check.go b/internal/regions/check.go index c23d4fdd8..c54d62ebd 100644 --- a/internal/regions/check.go +++ b/internal/regions/check.go @@ -13,6 +13,8 @@ const ( COUNTRY = "country" PROVINCE = "province" CITY = "city" + + InvalidRegionPrefix = "Invalid region, " ) func CheckCountry(countryCode string) bool { @@ -55,14 +57,14 @@ func GetRegionDetails(regionString string) (Region, error) { countryCode = subStrings[0] regionType = COUNTRY if !CheckCountry(countryCode) { - err = errors.New("country not found: " + countryCode) + err = errors.New(InvalidRegionPrefix + "country not found: " + countryCode) } case 2: countryCode = subStrings[1] provinceCode = subStrings[0] regionType = PROVINCE if !CheckProvince(countryCode, provinceCode) { - err = errors.New("country/province not found: " + countryCode + "-" + provinceCode) + err = errors.New(InvalidRegionPrefix + "country/province not found: " + countryCode + "-" + provinceCode) } default: countryCode = subStrings[2] @@ -70,7 +72,7 @@ func GetRegionDetails(regionString string) (Region, error) { cityCode = subStrings[0] regionType = CITY if !CheckCity(countryCode, provinceCode, cityCode) { - err = errors.New("country/province/city not found: " + countryCode + "-" + provinceCode + "-" + cityCode) + err = errors.New(InvalidRegionPrefix + "country/province/city not found: " + countryCode + "-" + provinceCode + "-" + cityCode) } } diff --git a/internal/regions/init.go b/internal/regions/init.go index 925c4a356..8814a2701 100644 --- a/internal/regions/init.go +++ b/internal/regions/init.go @@ -2,8 +2,6 @@ package regions import ( "challenge16/utils" - "fmt" - "log" ) const ( @@ -24,14 +22,6 @@ type ( var Countries = make(map[string]countryData) -func init() { - // Load data from CSV into the countries map - err := LoadDataIntoMap(filePath) - if err != nil { - log.Fatal(fmt.Errorf("error loading data into map: %w", err)) - } -} - func LoadDataIntoMap(csvFilePath string) error { // Load data from CSV into the countries map diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 000000000..18ee1b856 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,53 @@ +package server + +import ( + "challenge16/internal/config" + "challenge16/internal/handler" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func NewServer() *fiber.App { + app := fiber.New() + app.Use(logger.New()) + app.Use(limiter.New(limiter.Config{ + Max: config.RateLimit, + Expiration: 1 * time.Minute, + })) + + handler := handler.NewHandler() + + // Initialize the routes + { + // Distributor routes + distributor := app.Group("/distributor") + { + distributor.Post("/", handler.AddDistributor) + distributor.Delete("/:distributor", handler.RemoveDistributor) + distributor.Get("/", handler.GetDistributors) + } + + // Permission routes + permission := app.Group("/permission") + { + permission.Get("/check", handler.CheckIfDistributionIsAllowed) + permission.Post("/allow", handler.AllowDistribution) + permission.Post("/contract", handler.ApplyContract) + permission.Post("/disallow", handler.DisallowDistribution) + permission.Get("/:distributor", handler.GetDistributorPermissions) + } + + // Region routes + regions := app.Group("/regions") + { + regions.Get("/countries", handler.GetCountries) + regions.Get("/provinces/:countryCode", handler.GetProvincesInCountry) + regions.Get("/cities/:countryCode/:provinceCode", handler.GetCitiesInProvince) + } + } + + return app +} From 3b511dd3bc459e6db4551e9f7dddb78d4e372d6d Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 7 Mar 2025 19:28:44 +0530 Subject: [PATCH 13/15] :test_tube: Integration testing (#15) --- Makefile | 5 +- README.md | 4 + go.mod | 4 + go.sum | 2 + tests/integration/contract_test.go | 210 ++++++++++++++++++++++++++ tests/integration/permission_test.go | 218 +++++++++++++++++++++++++++ tests/integration/test_utils.go | 69 +++++++++ 7 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 tests/integration/contract_test.go create mode 100644 tests/integration/permission_test.go create mode 100644 tests/integration/test_utils.go diff --git a/Makefile b/Makefile index de792bd17..045e33a94 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,7 @@ running: CompileDaemon -build="go build -o ./cmd/main ./cmd" -command=./cmd/main run: - go run ./cmd/main.go \ No newline at end of file + go run ./cmd/main.go + +test: + go test -v ./tests/... \ No newline at end of file diff --git a/README.md b/README.md index c1ff67e6f..b849d20fd 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ This project was developed as part of a machine task for a company interview pro - Contract-based permission management - Region-based distribution control +### ๐Ÿงช Integration Testing +- Integration tests implemented to verify system functionality +- Tests cover contract validation, permissions management and their inheritance. + ## ๐Ÿ“ Technical Notes - Thread-safe operations using read-write mutex locks - CSV-based region validation diff --git a/go.mod b/go.mod index dd2a681c1..63942bce9 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,12 @@ require ( github.com/go-playground/validator/v10 v10.25.0 github.com/gofiber/fiber/v2 v2.52.6 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.8.4 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -20,6 +22,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/tinylib/msgp v1.2.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -29,4 +32,5 @@ require ( golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 22b2e2e9e..51d89f682 100644 --- a/go.sum +++ b/go.sum @@ -55,5 +55,7 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/integration/contract_test.go b/tests/integration/contract_test.go new file mode 100644 index 000000000..ba3298e04 --- /dev/null +++ b/tests/integration/contract_test.go @@ -0,0 +1,210 @@ +package test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + SUCCESS = "SUCCESS" + INVALID_CONTRACT = "INVALID_CONTRACT" + PARENT_DISTRIBUTOR_NOT_FOUND = "PARENT_DISTRIBUTOR_NOT_FOUND" + DISTRIBUTOR_NOT_FOUND = "DISTRIBUTOR_NOT_FOUND" +) + +func TestApplyContractSelfValidation(t *testing.T) { + ts := SetupIntegrationTest(t) + defer CleanupTest(t, ts) + + tests := []struct { + name string + contract string + expectedStatusCode int + expectedStatus bool + expectedResponseCode string + // expectedError string + }{ + + { + name: "Empty distributor", + contract: `Permissions for +INCLUDE: PT +INCLUDE: US`, + expectedStatusCode: http.StatusBadRequest, + expectedStatus: false, + expectedResponseCode: INVALID_CONTRACT, + }, + { + name: "Invalid heading format", + contract: `Permissifsfdsfdsf DISTRIBUTOR2 < DISTRIBUTOR1 +INCLUDE: PT +INCLUDE: US`, + expectedStatusCode: http.StatusBadRequest, + expectedStatus: false, + expectedResponseCode: INVALID_CONTRACT, + }, + { + name: "Invalid line in contract", + contract: `Permissions for DISTRIBUTOR1 +INCLUDE: PT +INCLUDE: US +BLA BLA`, + expectedStatusCode: http.StatusBadRequest, + expectedStatus: false, + expectedResponseCode: INVALID_CONTRACT, + }, + { + name: "Non-existent parent distributor", + contract: `Permissions for DISTRIBUTOR2 < DISTRIBUTOR121323 +INCLUDE: IN +INCLUDE: US`, + expectedStatusCode: http.StatusNotFound, + expectedStatus: false, + expectedResponseCode: PARENT_DISTRIBUTOR_NOT_FOUND, + }, + { + name: "Invalid region", + contract: `Permissions for DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KAA-IN`, + expectedStatusCode: http.StatusNotFound, + expectedStatus: false, + expectedResponseCode: "REGION_NOT_FOUND", + }, + { + name: "Duplicate distributor", + contract: `Permissions for DISTRIBUTOR1 < DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US`, + expectedStatusCode: http.StatusBadRequest, + expectedStatus: false, + expectedResponseCode: INVALID_CONTRACT, + }, + { + name: "Valid contract with only excludes", + contract: `Permissions for DISTRIBUTOR4 +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusBadRequest, + expectedStatus: false, + expectedResponseCode: INVALID_CONTRACT, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/permission/contract", strings.NewReader(tt.contract)) + req.Header.Set("Content-Type", "text/plain") + + resp, err := ts.App.Test(req) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatusCode, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + var response Response + err = json.Unmarshal(body, &response) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatus, response.Status) + assert.Equal(t, tt.expectedResponseCode, response.ResponseCode) + }) + } +} + +func TestApplyContractWithParentExistence(t *testing.T) { + ts := SetupIntegrationTest(t) + defer CleanupTest(t, ts) + + // First create parent distributor + parentContract := `Permissions for DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US` + + req := httptest.NewRequest("POST", "/permission/contract", strings.NewReader(parentContract)) + req.Header.Set("Content-Type", "text/plain") + resp, err := ts.App.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + tests := []struct { + name string + contract string + expectedStatusCode int + expectedStatus bool + expectedResponseCode string + }{ + { + + name: "Valid contract with existing parent", + contract: `Permissions for DISTRIBUTOR2 < DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + }, + { + + name: "Valid contract with existing parent", + contract: `Permissions for DISTRIBUTOR2 < DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + }, + { + + name: "Valid contract with non-existing parent", + contract: `Permissions for DISTRIBUTOR2 < DISTRIBUTOR243434 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusNotFound, + expectedStatus: false, + expectedResponseCode: PARENT_DISTRIBUTOR_NOT_FOUND, + }, + { + name: "Valid contract without parent", + contract: `Permissions for DISTRIBUTOR3 +INCLUDE: IN +INCLUDE: US`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/permission/contract", strings.NewReader(tt.contract)) + req.Header.Set("Content-Type", "text/plain") + + resp, err := ts.App.Test(req) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatusCode, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + var response Response + err = json.Unmarshal(body, &response) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatus, response.Status) + assert.Equal(t, tt.expectedResponseCode, response.ResponseCode) + }) + } +} diff --git a/tests/integration/permission_test.go b/tests/integration/permission_test.go new file mode 100644 index 000000000..579ed5e2e --- /dev/null +++ b/tests/integration/permission_test.go @@ -0,0 +1,218 @@ +package test + +import ( + "challenge16/internal/dto" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDistributorPermissionsFlow(t *testing.T) { + ts := SetupIntegrationTest(t) + defer CleanupTest(t, ts) + + tests := []struct { + name string + contract string + expectedStatusCode int + expectedStatus bool + expectedResponseCode string + + recipientDistributor string + expectedStatusCodeInGet int + expectedStatusInGet bool + expectedResponseCodeInGet string + expectedIncluded []string + expectedExcluded []string + }{ + { + name: "Initial contract", + contract: `Permissions for DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + + recipientDistributor: "DISTRIBUTOR1", + expectedStatusCodeInGet: http.StatusOK, + expectedStatusInGet: true, + expectedResponseCodeInGet: SUCCESS, + expectedIncluded: []string{"IN", "US"}, + expectedExcluded: []string{"KA-IN", "CENAI-TN-IN"}, + }, + { + name: "Sub contract with same inclusion and exclusion", + contract: `Permissions for DISTRIBUTOR2 < DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + + recipientDistributor: "DISTRIBUTOR2", + expectedStatusCodeInGet: http.StatusOK, + expectedStatusInGet: true, + expectedResponseCodeInGet: SUCCESS, + expectedIncluded: []string{"IN", "US"}, + expectedExcluded: []string{"KA-IN", "CENAI-TN-IN"}, + }, + { + name: "Sub contract with syntax mistake", + contract: `Permissionss for DISTRIBUTOR3 < DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN`, + expectedStatusCode: http.StatusBadRequest, + expectedStatus: false, + expectedResponseCode: INVALID_CONTRACT, + + recipientDistributor: "DISTRIBUTOR3", + expectedStatusCodeInGet: http.StatusNotFound, + expectedStatusInGet: false, + expectedResponseCodeInGet: DISTRIBUTOR_NOT_FOUND, + }, + { + name: "Sub contract with different inclusion and exclusion", + contract: `Permissions for DISTRIBUTOR3 < DISTRIBUTOR1 +INCLUDE: IN +EXCLUDE: KA-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + + recipientDistributor: "DISTRIBUTOR3", + expectedStatusCodeInGet: http.StatusOK, + expectedStatusInGet: true, + expectedResponseCodeInGet: SUCCESS, + expectedIncluded: []string{"IN"}, + expectedExcluded: []string{"KA-IN", "CENAI-TN-IN"}, + }, + { + name: "Sub contract with extra inclusion and exclusion", + contract: `Permissions for DISTRIBUTOR4 < DISTRIBUTOR1 +INCLUDE: IN +INCLUDE: US +INCLUDE: PA +EXCLUDE: KA-IN +EXCLUDE: CENAI-TN-IN +EXCLUDE: GJ-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + + recipientDistributor: "DISTRIBUTOR4", + expectedStatusCodeInGet: http.StatusOK, + expectedStatusInGet: true, + expectedResponseCodeInGet: SUCCESS, + expectedIncluded: []string{"IN", "US"}, + expectedExcluded: []string{"KA-IN", "CENAI-TN-IN", "GJ-IN"}, + }, + { + name: "Misc fresh contract", + contract: `Permissions for DISTRIBUTOR5 +INCLUDE: IN +INCLUDE: PA +EXCLUDE: KA-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + + recipientDistributor: "DISTRIBUTOR5", + expectedStatusCodeInGet: http.StatusOK, + expectedStatusInGet: true, + expectedResponseCodeInGet: SUCCESS, + expectedIncluded: []string{"IN", "PA"}, + expectedExcluded: []string{"KA-IN"}, + }, + { + name: "Giving sub-contract to distributor having some permissions", + contract: `Permissions for DISTRIBUTOR5 < DISTRIBUTOR1 +INCLUDE: IN +EXCLUDE: EM-PA +EXCLUDE: GJ-IN`, + expectedStatusCode: http.StatusOK, + expectedStatus: true, + expectedResponseCode: SUCCESS, + + recipientDistributor: "DISTRIBUTOR5", + expectedStatusCodeInGet: http.StatusOK, + expectedStatusInGet: true, + expectedResponseCodeInGet: SUCCESS, + expectedIncluded: []string{"IN", "PA"}, + expectedExcluded: []string{"KA-IN"}, //CENA-TN-IN is not excluded because it is already permitted for DISTRIBUTOR5, so exclusion in contract is ignored + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // POST request to create a contract + req := httptest.NewRequest("POST", "/permission/contract", strings.NewReader(tt.contract)) + req.Header.Set("Content-Type", "text/plain") + + resp, err := ts.App.Test(req) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatusCode, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + + var response Response + err = json.Unmarshal(body, &response) + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatus, response.Status) + assert.Equal(t, tt.expectedResponseCode, response.ResponseCode) + + if !tt.expectedStatus == response.Status { + t.Skip("Skipping GET request test: No valid distributor found in contract") + } + + // GET request to verify stored permissions + getReq := httptest.NewRequest("GET", fmt.Sprintf("/permission/%s?type=json", tt.recipientDistributor), nil) + getResp, err := ts.App.Test(getReq) + assert.NoError(t, err) + + body, err = io.ReadAll(getResp.Body) + assert.NoError(t, err) + + assert.Equal(t, tt.expectedStatusCodeInGet, getResp.StatusCode) + + var getResponse struct { + Status bool `json:"status"` + RespCode string `json:"resp_code"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + } + err = json.Unmarshal(body, &getResponse) + assert.NoError(t, err) + + assert.Equal(t, tt.expectedResponseCodeInGet, getResponse.RespCode) + assert.Equal(t, tt.expectedStatusInGet, getResponse.Status) + + if tt.expectedStatusInGet == getResponse.Status { + dataBytes, err := json.Marshal(getResponse.Data) + assert.NoError(t, err) + + var permissionData dto.GetPermissionsData + err = json.Unmarshal(dataBytes, &permissionData) + assert.NoError(t, err) + + // Compare included and excluded regions + assert.ElementsMatch(t, tt.expectedIncluded, permissionData.Included) + assert.ElementsMatch(t, tt.expectedExcluded, permissionData.Excluded) + } + + }) + } +} diff --git a/tests/integration/test_utils.go b/tests/integration/test_utils.go new file mode 100644 index 000000000..97ec78085 --- /dev/null +++ b/tests/integration/test_utils.go @@ -0,0 +1,69 @@ +package test + +import ( + "challenge16/internal/config" + "challenge16/internal/regions" + "challenge16/internal/server" + "sync" + "testing" + + "github.com/gofiber/fiber/v2" +) + +const ( + csvFile = "../../cities.csv" + envPath = "../../.env" +) + +type Response struct { + Status bool `json:"status"` + ResponseCode string `json:"resp_code"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type Permission struct { + Included []string + Excluded []string +} + +// TestSetup contains all the dependencies needed for testing +type TestSetup struct { + App *fiber.App + Cleanup func() + permStore map[string]Permission + permStoreMutex sync.RWMutex +} + +// SetupIntegrationTest prepares the test environment +func SetupIntegrationTest(t *testing.T) *TestSetup { + ts := &TestSetup{ + permStore: make(map[string]Permission), + } + + err := regions.LoadDataIntoMap(csvFile) + if err != nil { + t.Fatalf("Error loading data into map: %v", err) + } + + //initialize the environment configuration + config.LoadEnv(envPath) + + app := server.NewServer() + + ts.App = app + ts.Cleanup = func() { + ts.permStoreMutex.Lock() + ts.permStore = make(map[string]Permission) + ts.permStoreMutex.Unlock() + } + + return ts +} + +// CleanupTest performs necessary cleanup after tests +func CleanupTest(t *testing.T, ts *TestSetup) { + if ts.Cleanup != nil { + ts.Cleanup() + } +} From 756b2f397daa8839739bed0c3b0d8dacb18d5582 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 7 Mar 2025 22:57:07 +0530 Subject: [PATCH 14/15] :mag: Contract validation improvements (#17) * Space tolerance * Empty line tolerance * Same region cannot be included and excluded at same time * A region can be excluded only if its parent is included * A region cannot be excluded while including its sub-regions --- .gitignore | 3 ++- internal/data/process_contract.go | 38 +++++++++++++++++++++++++++++++ internal/dto/dto.go | 4 ++-- internal/handler/permission.go | 27 ++++++++++++++++------ 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 330ae6dcd..a0867ca4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ main .vscode __debug_bin* -.env \ No newline at end of file +.env +.DS_Store \ No newline at end of file diff --git a/internal/data/process_contract.go b/internal/data/process_contract.go index e511f57ac..a1fd73560 100644 --- a/internal/data/process_contract.go +++ b/internal/data/process_contract.go @@ -236,6 +236,44 @@ func validateContract(contract dto.Contract) error { } } + for country := range contract.ExcludedProvinces { + for province := range contract.ExcludedProvinces[country] { + if _, exists := contract.IncludedCities[country]; exists && len(contract.IncludedCities[country][province]) > 0 { + return fmt.Errorf("province %s in country %s is excluded, but its cities are included. A region cannot be excluded while including its sub-regions", province, country) + } + + // A province can be excluded only if its country is included; otherwise, it's meaningless. + if !contract.IncludedCountries[country] { + return fmt.Errorf("province %s in country %s is excluded, but the country is not included. A region can be excluded only if its parent is included.", province, country) + } + + // A province should not be included and excluded at the same time + if _, exists := contract.IncludedProvinces[country]; exists && contract.IncludedProvinces[country][province] { + return fmt.Errorf("province %s in country %s cannot be both included and excluded", province, country) + } + } + } + + for country := range contract.ExcludedCities { + for province := range contract.ExcludedCities[country] { + for city := range contract.ExcludedCities[country][province] { + // A city can be excluded only when either its province is included or its country is included without excluding the province. + if !contract.IncludedCountries[country] { + if _, exists := contract.IncludedProvinces[country]; !exists || !contract.IncludedProvinces[country][province] { + return fmt.Errorf("city %s in province %s in country %s is excluded, but the country is not included and the province is not included. A region cannot be excluded while its parent region is not included", city, province, country) + } + } + + // A city should not be included and excluded at the same time + if _, exists := contract.IncludedCities[country]; exists { + if _, exists := contract.IncludedCities[country][province]; exists && contract.IncludedCities[country][province][city] { + return fmt.Errorf("city %s in province %s in country %s cannot be both included and excluded", city, province, country) + } + } + } + } + } + return nil } diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 5c16a32b9..8ae1be5cc 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -64,8 +64,8 @@ func (c *Contract) AddExcludedRegion(regionString string) error { } switch region.Type { - case regions.COUNTRY: - c.IncludedCountries[region.CountryCode] = false + case regions.COUNTRY: //its meaningless to exclude a country, as there is no world level inclusion to exclude from + // c.IncludedCountries[region.CountryCode] = false case regions.PROVINCE: if c.ExcludedProvinces[region.CountryCode] == nil { c.ExcludedProvinces[region.CountryCode] = make(map[string]bool) diff --git a/internal/handler/permission.go b/internal/handler/permission.go index 0e6e4376d..34276c2ac 100644 --- a/internal/handler/permission.go +++ b/internal/handler/permission.go @@ -117,7 +117,7 @@ func getContractData(contractText string) (*dto.Contract, error) { } distributorHeirarchyText := strings.TrimPrefix(heading, "Permissions for ") - distributorHeirarchyText = strings.ReplaceAll(distributorHeirarchyText, " ", "") //Remove spaces + distributorHeirarchyText = strings.ReplaceAll(distributorHeirarchyText, " ", "") //Remove spaces for space-typo tolerance (extra spaces) distributorHeirarchy := strings.Split(distributorHeirarchyText, "<") switch len(distributorHeirarchy) { case 0: @@ -147,27 +147,40 @@ func getContractData(contractText string) (*dto.Contract, error) { for _, data := range contractData[1:] { data = strings.TrimLeft(data, " ") switch { - case strings.HasPrefix(data, "INCLUDE: "): - data = strings.TrimPrefix(data, "INCLUDE: ") + case strings.HasPrefix(data, "INCLUDE:"): + data = strings.TrimPrefix(data, "INCLUDE:") + data = strings.ReplaceAll(data, " ", "") //for space-typo tolerance (extra spaces) err = contract.AddIncludedRegion(data) if err != nil { return nil, err } - case strings.HasPrefix(data, "EXCLUDE: "): - data = strings.TrimPrefix(data, "EXCLUDE: ") + case strings.HasPrefix(data, "EXCLUDE:"): + line := data + data = strings.TrimPrefix(data, "EXCLUDE:") + data = strings.ReplaceAll(data, " ", "") //for space-typo tolerance (extra spaces) + if !strings.Contains(data, "-") { + //only country is mentioned + if !regions.CheckCountry(data) { + return nil, errors.New(regions.InvalidRegionPrefix + data) + } else { + return nil, errors.New("excluding a country(line:'" + line + "') is meaningless since there's no world-level inclusion to exclude from") + } + } + err = contract.AddExcludedRegion(data) if err != nil { return nil, err } + case data == "": //empty line + continue default: return nil, errors.New("Invalid contract, invalid line found: " + data) } } if len(contract.IncludedCountries) == 0 && len(contract.IncludedProvinces) == 0 && len(contract.IncludedCities) == 0 { - err = errors.New("Invalid contract, no included regions found in contract") - return nil, err + return nil, errors.New("Invalid contract, no included regions found in contract") } return &contract, nil From 3abbad10a122eefd8095954a8df23bddd0770665 Mon Sep 17 00:00:00 2001 From: Abdul Rahim O M Date: Fri, 7 Mar 2025 23:46:24 +0530 Subject: [PATCH 15/15] :rocket: No rate-limit for testing (#18) Effectively, no rate-limit now for testing to make the testing work proper --- cmd/main.go | 2 +- internal/server/server.go | 5 ++--- tests/integration/permission_test.go | 3 +-- tests/integration/test_utils.go | 7 +------ 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 918265784..8c0132dc7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,7 +19,7 @@ func main() { //initialize the environment configuration config.LoadEnv(envPath) - app := server.NewServer() + app := server.NewServer(config.RateLimit) err := app.Listen(fmt.Sprintf(":%s", config.Port)) if err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 18ee1b856..5279c2167 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,7 +1,6 @@ package server import ( - "challenge16/internal/config" "challenge16/internal/handler" "time" @@ -10,11 +9,11 @@ import ( "github.com/gofiber/fiber/v2/middleware/logger" ) -func NewServer() *fiber.App { +func NewServer(rateLimit int) *fiber.App { app := fiber.New() app.Use(logger.New()) app.Use(limiter.New(limiter.Config{ - Max: config.RateLimit, + Max: rateLimit, Expiration: 1 * time.Minute, })) diff --git a/tests/integration/permission_test.go b/tests/integration/permission_test.go index 579ed5e2e..cb0977e84 100644 --- a/tests/integration/permission_test.go +++ b/tests/integration/permission_test.go @@ -140,8 +140,7 @@ EXCLUDE: KA-IN`, name: "Giving sub-contract to distributor having some permissions", contract: `Permissions for DISTRIBUTOR5 < DISTRIBUTOR1 INCLUDE: IN -EXCLUDE: EM-PA -EXCLUDE: GJ-IN`, +EXCLUDE: AP-IN`, expectedStatusCode: http.StatusOK, expectedStatus: true, expectedResponseCode: SUCCESS, diff --git a/tests/integration/test_utils.go b/tests/integration/test_utils.go index 97ec78085..4d1bb5288 100644 --- a/tests/integration/test_utils.go +++ b/tests/integration/test_utils.go @@ -1,7 +1,6 @@ package test import ( - "challenge16/internal/config" "challenge16/internal/regions" "challenge16/internal/server" "sync" @@ -12,7 +11,6 @@ import ( const ( csvFile = "../../cities.csv" - envPath = "../../.env" ) type Response struct { @@ -46,10 +44,7 @@ func SetupIntegrationTest(t *testing.T) *TestSetup { t.Fatalf("Error loading data into map: %v", err) } - //initialize the environment configuration - config.LoadEnv(envPath) - - app := server.NewServer() + app := server.NewServer(1000000000) //effectively no rate limit ts.App = app ts.Cleanup = func() {