diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..a374482ff --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=4010 +RATE_LIMIT=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a0867ca4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +main +.vscode +__debug_bin* +.env +.DS_Store \ No newline at end of file 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/Makefile b/Makefile new file mode 100644 index 000000000..045e33a94 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +running: + CompileDaemon -build="go build -o ./cmd/main ./cmd" -command=./cmd/main + +run: + 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 f1c342f65..b849d20fd 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,243 @@ -# 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 + +๐ŸŒ **Region Management** + - 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 + +### ๐Ÿงช Integration Testing +- Integration tests implemented to verify system functionality +- Tests cover contract validation, permissions management and their inheritance. -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. +## ๐Ÿ“ Technical Notes +- Thread-safe operations using read-write mutex locks +- CSV-based region validation +- Hierarchical region structure validation +- Contract template validation -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 +echo RATE_LIMIT="60" >> .env # Requests per minute limit ``` -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). + + +## ๐Ÿ› ๏ธ 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 -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. +#### 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" + ] + } + } + ``` -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. +## ๐Ÿš€ 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 new file mode 100644 index 000000000..8c0132dc7 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "challenge16/internal/config" + "challenge16/internal/regions" + "challenge16/internal/server" + "fmt" +) + +const ( + csvFile = "cities.csv" + envPath = ".env" +) + +func main() { + //initialize the region data + regions.LoadDataIntoMap(csvFile) + + //initialize the environment configuration + config.LoadEnv(envPath) + + app := server.NewServer(config.RateLimit) + + 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/go.mod b/go.mod new file mode 100644 index 000000000..63942bce9 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module challenge16 + +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 + 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 + 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/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 + 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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..51d89f682 --- /dev/null +++ b/go.sum @@ -0,0 +1,61 @@ +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/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= +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/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= +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/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/internal/config/env.go b/internal/config/env.go new file mode 100644 index 000000000..e8a0a7d47 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,47 @@ +package config + +import ( + "fmt" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +const ( + defaultPort = "4010" +) + +var ( + Port string + RateLimit int +) + +// func init() { +// loadEnv() +// } + +func LoadEnv(path string) { + fmt.Println("Loading .env file...") + //parse .env file + err := godotenv.Load(path) + if err != nil { + log.Fatal("Error loading .env file. err", err) + } + + Port = os.Getenv("PORT") + if Port == "" { + 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) + } + } + +} diff --git a/internal/data/data.go b/internal/data/data.go new file mode 100644 index 000000000..1daea07f8 --- /dev/null +++ b/internal/data/data.go @@ -0,0 +1,567 @@ +package data + +import ( + "challenge16/internal/dto" + "challenge16/internal/regions" + "challenge16/internal/response" + "errors" + "fmt" + "strings" + "sync" +) + +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 struct { + Distributors map[string]permissionData + mu sync.RWMutex + } +) + +func NewDataBank() DataBank { + return DataBank{ + 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 { + if !db.distributorExists(distributor) { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) + } + + region, err := regions.GetRegionDetails(regionString) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } + + db.markAsIncluded(distributor, region) + return successResponse +} + +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() + + 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} + + //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 + } + } + 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}} + } + } + } + + //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) + } + } + + } + } + } +} + +func (db *DataBank) MarkExclusion(distributor, regionString string) response.Response { + if !db.distributorExists(distributor) { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) + } + + region, err := regions.GetRegionDetails(regionString) + if err != nil { + return response.CreateError(404, REGION_NOT_FOUND, err) + } + + db.markAsExcluded(distributor, region) + return successResponse +} + +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() + + 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) + } + + } + + 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}} + } + } + + } + + } + +} + +func (db *DataBank) AddDistributor(distributor string) response.Response { + if db.distributorExists(distributor) { + return response.CreateError(400, "DISTRIBUTOR_EXISTS", ErrDistributorExists) + } + db.mu.Lock() + defer db.mu.Unlock() + db.Distributors[distributor] = newPermissionData() + return createdResponse +} + +func (db *DataBank) RemoveDistributor(distributor string) response.Response { + 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) 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 + } + case PROVINCE: + if permissionData.includedCountries[countryCode] { + if _, exists := permissionData.excludedProvinces[countryCode]; exists && permissionData.excludedProvinces[countryCode][provinceCode] { + return false, FULLY_DENIED + } + 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 + } + } + } + } + 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 + } + } + } + + return false, "UNKNOWN" //this should never happen, as the region type is already validated +} + +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, + }) +} + +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) + } + + if !db.distributorExists(distributor) { + return response.CreateError(404, DISTRIBUTOR_NOT_FOUND, fmt.Errorf("distributor %s not found", distributor)) + } + + _, status := db.isAllowedForTheDistributor(distributor, region) + return response.CreateSuccess(200, status, nil) +} + +func (db *DataBank) distributorExists(distributor string) bool { + db.mu.RLock() + defer db.mu.RUnlock() + 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 "Distributor not found" + } + + 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) + } + } + } + + 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 builder.String() +} + +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)) + } + + 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) + } + } + } + + data:=dto.GetPermissionsData{ + Distributor: distributor, + Included: inclusions, + Excluded: exclusions, + } + + return response.CreateSuccess(200, "SUCCESS", data) +} diff --git a/internal/data/process_contract.go b/internal/data/process_contract.go new file mode 100644 index 000000000..a1fd73560 --- /dev/null +++ b/internal/data/process_contract.go @@ -0,0 +1,518 @@ +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] { + if _, exists := contractPermissions.ExcludedProvinces[country]; !exists { + contractPermissions.ExcludedProvinces[country] = make(map[string]bool) + } + 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 { + //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) + } + } + } + } + } + + 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 +} + +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(404, "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..8ae1be5cc --- /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: //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) + } + 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/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/distributer.go b/internal/handler/distributer.go new file mode 100644 index 000000000..fc35f2bf3 --- /dev/null +++ b/internal/handler/distributer.go @@ -0,0 +1,37 @@ +package handler + +import ( + "challenge16/internal/response" + "challenge16/utils/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) 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) +} + +func (h *handler) GetDistributors(c *fiber.Ctx) error { + resp := h.databank.GetDistributors() + 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..34276c2ac --- /dev/null +++ b/internal/handler/permission.go @@ -0,0 +1,202 @@ +package handler + +import ( + "challenge16/internal/dto" + "challenge16/internal/regions" + "challenge16/internal/response" + "challenge16/utils/validation" + "errors" + "strings" + + "github.com/gofiber/fiber/v2" +) + +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(checkPermissionRequest) + + if ok, err := validation.BindAndValidateURLQueryRequest(c, req); !ok { + return err + } + + resp := h.databank.CheckIfDistributionIsAllowed(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) +} + +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", + Error: err, + }.WriteToJSON(c) + } + resp := h.databank.ApplyContract(*contract) + return resp.WriteToJSON(c) +} + +func getContractData(contractText string) (*dto.Contract, 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 + */ + 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 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 nil, err + } + + distributorHeirarchyText := strings.TrimPrefix(heading, "Permissions for ") + distributorHeirarchyText = strings.ReplaceAll(distributorHeirarchyText, " ", "") //Remove spaces for space-typo tolerance (extra 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:] { + data = strings.TrimLeft(data, " ") + switch { + 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:"): + 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 { + return nil, errors.New("Invalid contract, no included regions found in contract") + } + + 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) + } + + 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 new file mode 100644 index 000000000..bae89ece3 --- /dev/null +++ b/internal/handler/regions.go @@ -0,0 +1,53 @@ +package handler + +import ( + "challenge16/internal/regions" + "challenge16/internal/response" + "fmt" + + "github.com/gofiber/fiber/v2" +) + +const ( + INVALID_REGION = "INVALID_REGION" +) + +func (h *handler) GetCountries(c *fiber.Ctx) error { + countries := regions.GetCountries() + return response.CreateSuccess(200, "SUCCESS", map[string]interface{}{ + "countries": countries, + }).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) + } + 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 { + 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) + } + 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 new file mode 100644 index 000000000..c54d62ebd --- /dev/null +++ b/internal/regions/check.go @@ -0,0 +1,86 @@ +package regions + +import ( + "errors" + "strings" +) + +const ( + allowAll = "allow-all" + denyAll = "deny-all" + custom = "custom" + + COUNTRY = "country" + PROVINCE = "province" + CITY = "city" + + InvalidRegionPrefix = "Invalid region, " +) + +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 +} + +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] + regionType = COUNTRY + if !CheckCountry(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(InvalidRegionPrefix + "country/province not found: " + countryCode + "-" + provinceCode) + } + default: + countryCode = subStrings[2] + provinceCode = subStrings[1] + cityCode = subStrings[0] + regionType = CITY + if !CheckCity(countryCode, provinceCode, cityCode) { + err = errors.New(InvalidRegionPrefix + "country/province/city not found: " + countryCode + "-" + provinceCode + "-" + cityCode) + } + } + + 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 new file mode 100644 index 000000000..b1ef1a406 --- /dev/null +++ b/internal/regions/get.go @@ -0,0 +1,50 @@ +package regions + +type regionInfo struct { + Name string `json:"name"` + Code string `json:"code"` +} + +func GetCountries() []regionInfo { + countries := make([]regionInfo, 0, len(Countries)) + for code, country := range Countries { + countries = append(countries, regionInfo{ + Name: country.Name, + Code: code, + }) + } + return countries +} + +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{ + Name: province.Name, + Code: code, + }) + } + return provinces +} + +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 { + cities = append(cities, regionInfo{ + Name: name, + Code: code, + }) + } + return cities +} diff --git a/internal/regions/init.go b/internal/regions/init.go new file mode 100644 index 000000000..8814a2701 --- /dev/null +++ b/internal/regions/init.go @@ -0,0 +1,60 @@ +package regions + +import ( + "challenge16/utils" +) + +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 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..d01d95fc5 --- /dev/null +++ b/internal/response/create_error.go @@ -0,0 +1,32 @@ +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, + 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, + } +} + +func InvalidURLParamResponse(param string, err error) Response { + return CreateError(http.StatusBadRequest, INVALID_URL_PARAM, fmt.Errorf("error parsing %v from url: %w", param, err)) +} 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/internal/server/server.go b/internal/server/server.go new file mode 100644 index 000000000..5279c2167 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,52 @@ +package server + +import ( + "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(rateLimit int) *fiber.App { + app := fiber.New() + app.Use(logger.New()) + app.Use(limiter.New(limiter.Config{ + Max: 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 +} 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..cb0977e84 --- /dev/null +++ b/tests/integration/permission_test.go @@ -0,0 +1,217 @@ +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: AP-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..4d1bb5288 --- /dev/null +++ b/tests/integration/test_utils.go @@ -0,0 +1,64 @@ +package test + +import ( + "challenge16/internal/regions" + "challenge16/internal/server" + "sync" + "testing" + + "github.com/gofiber/fiber/v2" +) + +const ( + csvFile = "../../cities.csv" +) + +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) + } + + app := server.NewServer(1000000000) //effectively no rate limit + + 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() + } +} 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/utils/validation/handle_request.go b/utils/validation/handle_request.go new file mode 100644 index 000000000..67cffd002 --- /dev/null +++ b/utils/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/utils/validation/validation.go b/utils/validation/validation.go new file mode 100644 index 000000000..031b2b4f7 --- /dev/null +++ b/utils/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 +}