├── .github └── workflows │ └── maven.yml ├── .gitignore ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── documentation ├── database_diagram.png └── openapi.yaml ├── lombok.config ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── behl │ │ └── overseer │ │ ├── RateLimitingApiApplication.java │ │ ├── configuration │ │ ├── BypassRateLimit.java │ │ ├── OpenApiConfiguration.java │ │ ├── OpenApiConfigurationProperties.java │ │ ├── PublicEndpoint.java │ │ ├── RedisConfiguration.java │ │ ├── SecurityConfiguration.java │ │ └── TokenConfigurationProperties.java │ │ ├── controller │ │ ├── AuthenticationController.java │ │ ├── JokeController.java │ │ └── PlanController.java │ │ ├── dto │ │ ├── ExceptionResponseDto.java │ │ ├── JokeResponseDto.java │ │ ├── PlanResponseDto.java │ │ ├── PlanUpdationRequestDto.java │ │ ├── TokenSuccessResponseDto.java │ │ ├── UserCreationRequestDto.java │ │ └── UserLoginRequestDto.java │ │ ├── entity │ │ ├── Plan.java │ │ ├── User.java │ │ └── UserPlanMapping.java │ │ ├── exception │ │ ├── AccountAlreadyExistsException.java │ │ ├── ExceptionResponseHandler.java │ │ ├── InvalidLoginCredentialsException.java │ │ └── InvalidPlanException.java │ │ ├── filter │ │ ├── JwtAuthenticationFilter.java │ │ └── RateLimitFilter.java │ │ ├── repository │ │ ├── PlanRepository.java │ │ ├── UserPlanMappingRepository.java │ │ └── UserRepository.java │ │ ├── service │ │ ├── PlanService.java │ │ ├── RateLimitingService.java │ │ └── UserService.java │ │ └── utility │ │ ├── ApiEndpointSecurityInspector.java │ │ ├── AuthenticatedUserIdProvider.java │ │ ├── JokeGenerator.java │ │ └── JwtUtility.java └── resources │ ├── application.yml │ └── db │ └── migration │ ├── V001__creating_database_tables.sql │ └── V002__adding_plans.sql └── test └── java └── com └── behl └── overseer ├── InitializeApplicationSecretKey.java ├── InitializeMysqlContainer.java ├── InitializeRedisContainer.java ├── MySQLDataSourceInitializer.java ├── RedisCacheInitializer.java ├── SecretKeyInitializer.java ├── controller ├── AuthenticationControllerIT.java ├── JokeControllerIT.java └── PlanControllerIT.java ├── repository ├── PlanRepositoryIT.java ├── UserPlanMappingRepositoryIT.java └── UserRepositoryIT.java ├── service ├── PlanServiceIT.java ├── PlanServiceTest.java ├── RateLimitingServiceIT.java ├── UserServiceIT.java └── UserServiceTest.java └── utility ├── ApiEndpointSecurityInspectorIT.java ├── AuthenticatedUserIdProviderTest.java └── JwtUtilityTest.java /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Rate limiting APIs using token bucket algorithm build 2 | 3 | on: 4 | push: 5 | branches: [ "main", "feature/*", "fix/*", "refactor/*" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check-out repository 15 | uses: actions/checkout@v4 16 | - name: Set up JDK 21 17 | uses: actions/setup-java@v4 18 | with: 19 | java-version: '21' 20 | distribution: 'adopt' 21 | cache: maven 22 | - name: Set up Docker 23 | uses: docker/setup-buildx-action@v3 24 | - name: Compile project 25 | run: mvn compile 26 | - name: Run unit tests 27 | run: mvn test 28 | - name: Run integration tests 29 | run: mvn integration-test verify 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | ### Mac OS ### 36 | .DS_Store 37 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hardikSinghBehl/rate-limiting-api-spring-boot/bbfcffc2dc1dd1bc2d3efbfc74897fa6e89b54a4/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.9-amazoncorretto-21 as backend 2 | WORKDIR /backend 3 | COPY pom.xml . 4 | COPY lombok.config . 5 | RUN mvn dependency:go-offline -B 6 | COPY src ./src 7 | RUN mvn clean install -DskipITs 8 | RUN mkdir -p target/dependency && (cd target/dependency; jar -xf ../*.jar) 9 | 10 | FROM openjdk:21 11 | ARG DEPENDENCY=/backend/target/dependency 12 | COPY --from=backend ${DEPENDENCY}/BOOT-INF/lib /app/lib 13 | COPY --from=backend ${DEPENDENCY}/META-INF /app/META-INF 14 | COPY --from=backend ${DEPENDENCY}/BOOT-INF/classes /app 15 | ENTRYPOINT ["java","-cp","app:app/lib/*","com.behl.overseer.RateLimitingApiApplication"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Rate limiting APIs using Token Bucket Algorithm 2 | ##### A reference proof-of-concept that leverages [Bucket4j](https://github.com/bucket4j/bucket4j) along with Redis Cache and Spring Security filters to implement Rate limiting on private API endpoints. 3 | ##### 🛠 upgraded to Spring Boot 3 and Spring Security 6 🛠 4 | 5 | ### Key Components 6 | * [RedisConfiguration.java](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/configuration/RedisConfiguration.java) 7 | * [RateLimitingService.java](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/service/RateLimitingService.java) 8 | * [RateLimitFilter.java](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/filter/RateLimitFilter.java) 9 | * [BypassRateLimit.java](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/configuration/BypassRateLimit.java) 10 | * [PublicEndpoint.java](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/configuration/PublicEndpoint.java) 11 | * [Flyway Migration Scripts](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/resources/db/migration) 12 | 13 | ### Application Flow 14 | * During the initial launch of the application, [Database tables](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/documentation/database_diagram.png) are created and populated with data using Flyway migration scripts. Specifically, the [plans](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/resources/db/migration/V002__adding_plans.sql) table is populated with predefined plans, each assigned a specific `limit_per_hour` value. 15 | 16 | | Name | Limit per Hour | 17 | |---------------|----------------| 18 | | FREE | 20 | 19 | | BUSINESS | 40 | 20 | | PROFESSIONAL | 100 | 21 | 22 | * When a user account is created, the specified plan-id sent as part of the request is linked to the user's record. This plan dictates the rate limit configurations applicable to the user. 23 | * When a user invokes a private API endpoint using a valid JWT token recieved post successful authentication, the application enforces rate limit based on the user's chosen plan. The rate limit enforcement occurs within the [RateLimitFilter](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/filter/RateLimitFilter.java), where the current configuration is managed by [RateLimitingService](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/service/RateLimitingService.java). 24 | * Upon initial API invocation, the RateLimitingService fetches the user's plan details from the datasource, storing them in the cache for efficient retrieval on subsequent requests. This data stored in the form of a Bucket, is used to implement Token Bucket Algorithm using [Bucket4j](https://github.com/bucket4j/bucket4j). 25 | * When the rate limit assigned gets exhausted for a user, the below API response is sent back to the client 26 | ``` 27 | { 28 | "Status": "429 TOO_MANY_REQUESTS", 29 | "Description": "API request limit linked to your current plan has been exhausted." 30 | } 31 | ``` 32 | * The current user plan can be updated, which removes the previous rate limit configuration stored in the cache. The private API endpoint to update plan has been configured to bypass rate limit checks using [@BypassRateLimit](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/configuration/BypassRateLimit.java), allowing access via a valid JWT token even when the current rate limit is exhausted. 33 | 34 | ### Rate Limit Headers 35 | After evaluation of incoming HTTP requests against the user's rate limit, the [RateLimitFilter](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/filter/RateLimitFilter.java) includes additional HTTP headers in the response to provide more information. These headers are useful for client applications to understand the rate limit status and adjust their behavior accordingly to handle rate limit violations gracefully. 36 | 37 | | Header Name | Description | 38 | |--------------------------------|---------------------------------------------------------------------------------------------------------------------| 39 | | X-Rate-Limit-Remaining | Indicates the number of remaining tokens available in the user's rate limit bucket after processing the request. | 40 | | X-Rate-Limit-Retry-After-Seconds | Specifies the wait period in seconds before the user can retry making requests, in case they exceed their rate limit. | 41 | 42 | ### Bypass Rate limit Enforcement 43 | Bypassing rate limit enforcement for specific private API endpoints can be achieved by annotating the corresponding controller method(s) with the `@BypassRateLimit` annotation. When applied, requests to that method are not subjected to rate limiting by the [RateLimitFilter.java](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/filter/RateLimitFilter.java) and allowed regardless of the user's current rate limit plan. 44 | 45 | The below private API endpoint to update a user's current plan is annotated with `@BypassRateLimit` to ensure requests to update to a new plan are not restricted by the user's rate limit. 46 | 47 | ```java 48 | @BypassRateLimit 49 | @PutMapping(value = "/api/v1/plan") 50 | public ResponseEntity update(@RequestBody PlanUpdationRequest planUpdationRequest) { 51 | planService.update(planUpdationRequest); 52 | return ResponseEntity.status(HttpStatus.OK).build(); 53 | } 54 | ``` 55 | 56 | ### Security Filters 57 | 58 | All requests to private API endpoints are intercepted by the [JwtAuthenticationFilter](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/filter/JwtAuthenticationFilter.java). This filter holds the responsibility for verifying the signature of the incoming access token and populating the security context. Only when the access token's signature is validated successfully, does the request reach [RateLimitFilter](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/filter/RateLimitFilter.java) which enforces the rate limit for the user accordingly. 59 | 60 | Both the custom filters are added to the Spring Security filter chain and configured in the [SecurityConfiguration](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/configuration/SecurityConfiguration.java). 61 | 62 | Any API that needs to be made public can be annotated with [@PublicEndpoint](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/configuration/PublicEndpoint.java). Requests to the configured API paths will not evaluated by either of the filters with the logic being governed by [ApiEndpointSecurityInspector](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/main/java/com/behl/overseer/utility/ApiEndpointSecurityInspector.java). 63 | 64 | Below is a sample controller method declared as public which will be exempted from authentication checks: 65 | 66 | ```java 67 | @PublicEndpoint 68 | @GetMapping(value = "/plan", produces = MediaType.APPLICATION_JSON_VALUE) 69 | public ResponseEntity> retrieve() { 70 | var response = planService.retrieve(); 71 | return ResponseEntity.ok(response); 72 | } 73 | ``` 74 | 75 | --- 76 | ### Testing 77 | 78 | [Testcontainers](https://github.com/testcontainers/testcontainers-java) have been utilized to effectively test the core functionality of Rate limiting in the application. 79 | 80 | The below two essential tests can be examined to gain insight into the functionality and behavior of the rate limiting feature employed in the application: 81 | 82 | * [RateLimitingServiceIT](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/test/java/com/behl/overseer/service/RateLimitingServiceIT.java) 83 | * [JokeControllerIT](https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/blob/main/src/test/java/com/behl/overseer/controller/JokeControllerIT.java) 84 | 85 | To run the entire Unit test and Integration test classes, the below commands can be executed respectively. 86 | 87 | ```bash 88 | mvn test 89 | ``` 90 | ```bash 91 | mvn integration-test 92 | ``` 93 | 94 | --- 95 | ### Local Setup 96 | The below given commands can be executed in the project's base directory to build an image and start required container(s). Docker compose will initiate a MySQL and Redis container as well, with the backend swagger-ui accessible at `http://localhost:8080/swagger-ui.html` 97 | ```bash 98 | sudo docker-compose build 99 | ``` 100 | ```bash 101 | sudo docker-compose up -d 102 | ``` 103 | 104 | --- 105 | ### Visual Walkthrough 106 | 107 | https://github.com/hardikSinghBehl/rate-limiting-api-spring-boot/assets/69693621/8a14800f-1fed-4ad7-8606-d8015d7f66a1 108 | 109 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # For a more secure and flexible setup, consider using environment variables from an .env file. 2 | # Reference: https://docs.docker.com/compose/environment-variables/set-environment-variables/ 3 | version: '3.7' 4 | 5 | services: 6 | mysql-datasource: 7 | image: mysql:8 8 | container_name: mysql-datasource 9 | environment: 10 | MYSQL_ROOT_PASSWORD: Password@123 11 | MYSQL_DATABASE: overseer 12 | MYSQL_USER: overseer 13 | MYSQL_PASSWORD: Password@123 14 | networks: 15 | - overseer 16 | 17 | redis-cache: 18 | image: redis 19 | container_name: redis-cache 20 | command: redis-server --requirepass "Password@123" 21 | networks: 22 | - overseer 23 | 24 | backend-application: 25 | build: 26 | context: ./ 27 | dockerfile: Dockerfile 28 | container_name: backend-application 29 | ports: 30 | - 8080:8080 31 | depends_on: 32 | - mysql-datasource 33 | - redis-cache 34 | environment: 35 | MYSQL_URL: jdbc:mysql://mysql-datasource:3306/overseer 36 | MYSQL_USERNAME: overseer 37 | MYSQL_PASSWORD: Password@123 38 | REDIS_HOSTNAME: redis-cache 39 | REDIS_PORT: 6379 40 | REDIS_PASSWORD: Password@123 41 | JWT_SECRET_KEY: 093617ebfa4b9af9700db274ac204ffa34195494d97b9c26c23ad561de817926 42 | networks: 43 | - overseer 44 | restart: on-failure:5 45 | 46 | networks: 47 | overseer: 48 | -------------------------------------------------------------------------------- /documentation/database_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hardikSinghBehl/rate-limiting-api-spring-boot/bbfcffc2dc1dd1bc2d3efbfc74897fa6e89b54a4/documentation/database_diagram.png -------------------------------------------------------------------------------- /documentation/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | version: 1.0.0 4 | title: Overseer 5 | description: Backend application imposing rate limit on private API endpoints using 6 | token bucket algorithm 7 | servers: 8 | - url: http://localhost:8080 9 | description: Local Backend Server 10 | tags: 11 | - name: Plan Management 12 | description: Endpoints for managing and retrieving available plan details 13 | - name: Authentication 14 | description: Endpoints for user account and authentication management 15 | - name: Joke Generator 16 | description: Endpoint for generating random unfunny joke 17 | paths: 18 | /api/v1/plan: 19 | get: 20 | tags: 21 | - Plan Management 22 | summary: Retrieves all available plans 23 | description: Retrieves the list of available plans in the system 24 | operationId: retrieve 25 | responses: 26 | "200": 27 | description: Plans retrieved successfully 28 | content: 29 | application/json: 30 | schema: 31 | type: array 32 | items: 33 | $ref: '#/components/schemas/PlanResponseDto' 34 | put: 35 | tags: 36 | - Plan Management 37 | summary: Update user plan 38 | description: Updates an existing plan of an authenticated user 39 | operationId: update 40 | requestBody: 41 | content: 42 | application/json: 43 | schema: 44 | $ref: '#/components/schemas/PlanUpdationRequestDto' 45 | required: true 46 | responses: 47 | "404": 48 | description: No plan exists in the system with provided-id 49 | content: 50 | '*/*': 51 | schema: 52 | $ref: '#/components/schemas/ExceptionResponseDto' 53 | "200": 54 | description: Plan updated successfully 55 | "400": 56 | description: Invalid request body 57 | content: 58 | '*/*': 59 | schema: 60 | $ref: '#/components/schemas/ExceptionResponseDto' 61 | "429": 62 | description: API rate limit exhausted 63 | content: 64 | '*/*': 65 | schema: 66 | $ref: '#/components/schemas/ExceptionResponseDto' 67 | /api/v1/user: 68 | post: 69 | tags: 70 | - Authentication 71 | summary: Creates a user record 72 | description: Creates a unique user record in the system corresponding to the 73 | provided information 74 | operationId: createUser 75 | requestBody: 76 | content: 77 | application/json: 78 | schema: 79 | $ref: '#/components/schemas/UserCreationRequestDto' 80 | required: true 81 | responses: 82 | "409": 83 | description: User account with provided email-id already exists 84 | content: 85 | '*/*': 86 | schema: 87 | $ref: '#/components/schemas/ExceptionResponseDto' 88 | "404": 89 | description: No plan exists in the system with provided-id 90 | content: 91 | '*/*': 92 | schema: 93 | $ref: '#/components/schemas/ExceptionResponseDto' 94 | "400": 95 | description: Invalid request body 96 | content: 97 | '*/*': 98 | schema: 99 | $ref: '#/components/schemas/ExceptionResponseDto' 100 | "201": 101 | description: User record created successfully 102 | /api/v1/auth/login: 103 | post: 104 | tags: 105 | - Authentication 106 | summary: Validates user login credentials 107 | description: Validates user login credentials and returns access-token on successful 108 | authentication 109 | operationId: login 110 | requestBody: 111 | content: 112 | application/json: 113 | schema: 114 | $ref: '#/components/schemas/UserLoginRequestDto' 115 | required: true 116 | responses: 117 | "400": 118 | description: Invalid request body 119 | content: 120 | '*/*': 121 | schema: 122 | $ref: '#/components/schemas/ExceptionResponseDto' 123 | "200": 124 | description: Authentication successfull 125 | content: 126 | '*/*': 127 | schema: 128 | $ref: '#/components/schemas/TokenSuccessResponseDto' 129 | "401": 130 | description: Invalid credentials provided. Failed to authenticate user 131 | content: 132 | '*/*': 133 | schema: 134 | $ref: '#/components/schemas/ExceptionResponseDto' 135 | /api/v1/joke: 136 | get: 137 | tags: 138 | - Joke Generator 139 | summary: Generates a random unfunny joke 140 | operationId: generate 141 | responses: 142 | "200": 143 | description: Successfully generated random unfunny joke 144 | headers: 145 | X-Rate-Limit-Remaining: 146 | description: The number of remaining API invocations available with 147 | the user after processing the request. 148 | required: true 149 | style: simple 150 | schema: 151 | type: integer 152 | content: 153 | application/json: 154 | schema: 155 | $ref: '#/components/schemas/JokeResponseDto' 156 | "429": 157 | description: API rate limit exhausted 158 | headers: 159 | X-Rate-Limit-Retry-After-Seconds: 160 | description: Wait period in seconds before the user can invoke the API 161 | endpoint 162 | required: true 163 | style: simple 164 | schema: 165 | type: integer 166 | content: 167 | application/json: 168 | schema: 169 | $ref: '#/components/schemas/ExceptionResponseDto' 170 | components: 171 | schemas: 172 | PlanUpdationRequestDto: 173 | title: PlanUpdationRequest 174 | required: 175 | - PlanId 176 | type: object 177 | properties: 178 | PlanId: 179 | type: string 180 | description: plan to be attached with user record 181 | format: uuid 182 | writeOnly: true 183 | ExceptionResponseDto: 184 | title: Error 185 | type: object 186 | properties: 187 | Status: 188 | type: string 189 | Description: 190 | type: object 191 | readOnly: true 192 | UserCreationRequestDto: 193 | title: UserCreationRequest 194 | required: 195 | - EmailId 196 | - Password 197 | - PlanId 198 | type: object 199 | properties: 200 | EmailId: 201 | type: string 202 | description: email-id of user 203 | example: hardik.behl7444@gmail.com 204 | Password: 205 | type: string 206 | description: secure password to enable user login 207 | example: somethingSecure 208 | PlanId: 209 | type: string 210 | description: plan to be attached with new user record 211 | format: uuid 212 | writeOnly: true 213 | UserLoginRequestDto: 214 | title: UserLoginRequest 215 | required: 216 | - EmailId 217 | - Password 218 | type: object 219 | properties: 220 | EmailId: 221 | type: string 222 | description: email-id associated with user account already created in the 223 | system 224 | example: hardik.behl7444@gmail.com 225 | Password: 226 | type: string 227 | description: password corresponding to provided email-id 228 | example: somethingSecure 229 | writeOnly: true 230 | TokenSuccessResponseDto: 231 | title: TokenSuccessResponse 232 | type: object 233 | properties: 234 | AccessToken: 235 | type: string 236 | readOnly: true 237 | PlanResponseDto: 238 | title: Plan 239 | type: object 240 | properties: 241 | Id: 242 | type: string 243 | format: uuid 244 | Name: 245 | type: string 246 | LimitPerHour: 247 | type: integer 248 | format: int32 249 | readOnly: true 250 | JokeResponseDto: 251 | title: Joke 252 | type: object 253 | properties: 254 | Joke: 255 | type: string 256 | readOnly: true 257 | securitySchemes: 258 | Bearer_Authentication: 259 | type: http 260 | scheme: Bearer 261 | security: 262 | - Bearer_Authentication: [] 263 | -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | lombok.nonNull.exceptionType=IllegalArgumentException -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.2.0 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | # e.g. to debug Maven itself, use 32 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | # ---------------------------------------------------------------------------- 35 | 36 | if [ -z "$MAVEN_SKIP_RC" ] ; then 37 | 38 | if [ -f /usr/local/etc/mavenrc ] ; then 39 | . /usr/local/etc/mavenrc 40 | fi 41 | 42 | if [ -f /etc/mavenrc ] ; then 43 | . /etc/mavenrc 44 | fi 45 | 46 | if [ -f "$HOME/.mavenrc" ] ; then 47 | . "$HOME/.mavenrc" 48 | fi 49 | 50 | fi 51 | 52 | # OS specific support. $var _must_ be set to either true or false. 53 | cygwin=false; 54 | darwin=false; 55 | mingw=false 56 | case "$(uname)" in 57 | CYGWIN*) cygwin=true ;; 58 | MINGW*) mingw=true;; 59 | Darwin*) darwin=true 60 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 61 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 62 | if [ -z "$JAVA_HOME" ]; then 63 | if [ -x "/usr/libexec/java_home" ]; then 64 | JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME 65 | else 66 | JAVA_HOME="/Library/Java/Home"; export JAVA_HOME 67 | fi 68 | fi 69 | ;; 70 | esac 71 | 72 | if [ -z "$JAVA_HOME" ] ; then 73 | if [ -r /etc/gentoo-release ] ; then 74 | JAVA_HOME=$(java-config --jre-home) 75 | fi 76 | fi 77 | 78 | # For Cygwin, ensure paths are in UNIX format before anything is touched 79 | if $cygwin ; then 80 | [ -n "$JAVA_HOME" ] && 81 | JAVA_HOME=$(cygpath --unix "$JAVA_HOME") 82 | [ -n "$CLASSPATH" ] && 83 | CLASSPATH=$(cygpath --path --unix "$CLASSPATH") 84 | fi 85 | 86 | # For Mingw, ensure paths are in UNIX format before anything is touched 87 | if $mingw ; then 88 | [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && 89 | JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" 90 | fi 91 | 92 | if [ -z "$JAVA_HOME" ]; then 93 | javaExecutable="$(which javac)" 94 | if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then 95 | # readlink(1) is not available as standard on Solaris 10. 96 | readLink=$(which readlink) 97 | if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then 98 | if $darwin ; then 99 | javaHome="$(dirname "\"$javaExecutable\"")" 100 | javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" 101 | else 102 | javaExecutable="$(readlink -f "\"$javaExecutable\"")" 103 | fi 104 | javaHome="$(dirname "\"$javaExecutable\"")" 105 | javaHome=$(expr "$javaHome" : '\(.*\)/bin') 106 | JAVA_HOME="$javaHome" 107 | export JAVA_HOME 108 | fi 109 | fi 110 | fi 111 | 112 | if [ -z "$JAVACMD" ] ; then 113 | if [ -n "$JAVA_HOME" ] ; then 114 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 115 | # IBM's JDK on AIX uses strange locations for the executables 116 | JAVACMD="$JAVA_HOME/jre/sh/java" 117 | else 118 | JAVACMD="$JAVA_HOME/bin/java" 119 | fi 120 | else 121 | JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" 122 | fi 123 | fi 124 | 125 | if [ ! -x "$JAVACMD" ] ; then 126 | echo "Error: JAVA_HOME is not defined correctly." >&2 127 | echo " We cannot execute $JAVACMD" >&2 128 | exit 1 129 | fi 130 | 131 | if [ -z "$JAVA_HOME" ] ; then 132 | echo "Warning: JAVA_HOME environment variable is not set." 133 | fi 134 | 135 | # traverses directory structure from process work directory to filesystem root 136 | # first directory with .mvn subdirectory is considered project base directory 137 | find_maven_basedir() { 138 | if [ -z "$1" ] 139 | then 140 | echo "Path not specified to find_maven_basedir" 141 | return 1 142 | fi 143 | 144 | basedir="$1" 145 | wdir="$1" 146 | while [ "$wdir" != '/' ] ; do 147 | if [ -d "$wdir"/.mvn ] ; then 148 | basedir=$wdir 149 | break 150 | fi 151 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 152 | if [ -d "${wdir}" ]; then 153 | wdir=$(cd "$wdir/.." || exit 1; pwd) 154 | fi 155 | # end of workaround 156 | done 157 | printf '%s' "$(cd "$basedir" || exit 1; pwd)" 158 | } 159 | 160 | # concatenates all lines of a file 161 | concat_lines() { 162 | if [ -f "$1" ]; then 163 | # Remove \r in case we run on Windows within Git Bash 164 | # and check out the repository with auto CRLF management 165 | # enabled. Otherwise, we may read lines that are delimited with 166 | # \r\n and produce $'-Xarg\r' rather than -Xarg due to word 167 | # splitting rules. 168 | tr -s '\r\n' ' ' < "$1" 169 | fi 170 | } 171 | 172 | log() { 173 | if [ "$MVNW_VERBOSE" = true ]; then 174 | printf '%s\n' "$1" 175 | fi 176 | } 177 | 178 | BASE_DIR=$(find_maven_basedir "$(dirname "$0")") 179 | if [ -z "$BASE_DIR" ]; then 180 | exit 1; 181 | fi 182 | 183 | MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR 184 | log "$MAVEN_PROJECTBASEDIR" 185 | 186 | ########################################################################################## 187 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 188 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 189 | ########################################################################################## 190 | wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" 191 | if [ -r "$wrapperJarPath" ]; then 192 | log "Found $wrapperJarPath" 193 | else 194 | log "Couldn't find $wrapperJarPath, downloading it ..." 195 | 196 | if [ -n "$MVNW_REPOURL" ]; then 197 | wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 198 | else 199 | wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 200 | fi 201 | while IFS="=" read -r key value; do 202 | # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) 203 | safeValue=$(echo "$value" | tr -d '\r') 204 | case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; 205 | esac 206 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 207 | log "Downloading from: $wrapperUrl" 208 | 209 | if $cygwin; then 210 | wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") 211 | fi 212 | 213 | if command -v wget > /dev/null; then 214 | log "Found wget ... using wget" 215 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" 216 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 217 | wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 218 | else 219 | wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 220 | fi 221 | elif command -v curl > /dev/null; then 222 | log "Found curl ... using curl" 223 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" 224 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 225 | curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 226 | else 227 | curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 228 | fi 229 | else 230 | log "Falling back to using Java to download" 231 | javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" 232 | javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" 233 | # For Cygwin, switch paths to Windows format before running javac 234 | if $cygwin; then 235 | javaSource=$(cygpath --path --windows "$javaSource") 236 | javaClass=$(cygpath --path --windows "$javaClass") 237 | fi 238 | if [ -e "$javaSource" ]; then 239 | if [ ! -e "$javaClass" ]; then 240 | log " - Compiling MavenWrapperDownloader.java ..." 241 | ("$JAVA_HOME/bin/javac" "$javaSource") 242 | fi 243 | if [ -e "$javaClass" ]; then 244 | log " - Running MavenWrapperDownloader.java ..." 245 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" 246 | fi 247 | fi 248 | fi 249 | fi 250 | ########################################################################################## 251 | # End of extension 252 | ########################################################################################## 253 | 254 | # If specified, validate the SHA-256 sum of the Maven wrapper jar file 255 | wrapperSha256Sum="" 256 | while IFS="=" read -r key value; do 257 | case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; 258 | esac 259 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 260 | if [ -n "$wrapperSha256Sum" ]; then 261 | wrapperSha256Result=false 262 | if command -v sha256sum > /dev/null; then 263 | if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then 264 | wrapperSha256Result=true 265 | fi 266 | elif command -v shasum > /dev/null; then 267 | if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then 268 | wrapperSha256Result=true 269 | fi 270 | else 271 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." 272 | echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." 273 | exit 1 274 | fi 275 | if [ $wrapperSha256Result = false ]; then 276 | echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 277 | echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 278 | echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 279 | exit 1 280 | fi 281 | fi 282 | 283 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 284 | 285 | # For Cygwin, switch paths to Windows format before running java 286 | if $cygwin; then 287 | [ -n "$JAVA_HOME" ] && 288 | JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") 289 | [ -n "$CLASSPATH" ] && 290 | CLASSPATH=$(cygpath --path --windows "$CLASSPATH") 291 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 292 | MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") 293 | fi 294 | 295 | # Provide a "standardized" way to retrieve the CLI args that will 296 | # work with both Windows and non-Windows executions. 297 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" 298 | export MAVEN_CMD_LINE_ARGS 299 | 300 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 301 | 302 | # shellcheck disable=SC2086 # safe args 303 | exec "$JAVACMD" \ 304 | $MAVEN_OPTS \ 305 | $MAVEN_DEBUG_OPTS \ 306 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 307 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 308 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 309 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 3.2.3 11 | 12 | 13 | 14 | com.behl 15 | rate-limiting-api-spring-boot 16 | 1.0.0 17 | rate-limiting-api-spring-boot 18 | backend application imposing rate limit on private API endpoints using token bucket algorithm 19 | 20 | 21 | 21 22 | 0.12.5 23 | 8.9.0 24 | 2.3.0 25 | 26 | 27 | 28 | 29 | hardikSinghBehl 30 | Hardik Singh Behl 31 | hardik.behl7444@gmail.com 32 | https://www.linkedin.com/in/hardiksinghbehl 33 | 34 | Backend Developer 35 | Java Web Developer 36 | 37 | UTC +5:30 38 | 39 | 40 | 41 | 42 | 43 | org.springframework.boot 44 | spring-boot-starter-web 45 | 46 | 47 | org.springframework.boot 48 | spring-boot-starter-data-jpa 49 | 50 | 51 | org.springframework.boot 52 | spring-boot-starter-data-redis 53 | 54 | 55 | org.springframework.boot 56 | spring-boot-starter-security 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-validation 61 | 62 | 63 | org.springframework.boot 64 | spring-boot-configuration-processor 65 | 66 | 67 | com.mysql 68 | mysql-connector-j 69 | 70 | 71 | org.flywaydb 72 | flyway-mysql 73 | 74 | 75 | com.bucket4j 76 | bucket4j-jcache 77 | ${bucket4j.version} 78 | 79 | 80 | javax.cache 81 | cache-api 82 | 83 | 84 | org.redisson 85 | redisson 86 | 3.27.1 87 | 88 | 89 | org.springdoc 90 | springdoc-openapi-starter-webmvc-ui 91 | ${springdoc.version} 92 | 93 | 94 | io.jsonwebtoken 95 | jjwt-impl 96 | ${jjwt.version} 97 | 98 | 99 | io.jsonwebtoken 100 | jjwt-jackson 101 | ${jjwt.version} 102 | 103 | 104 | org.projectlombok 105 | lombok 106 | true 107 | 108 | 109 | net.datafaker 110 | datafaker 111 | 2.1.0 112 | 113 | 114 | org.springframework.boot 115 | spring-boot-starter-test 116 | test 117 | 118 | 119 | org.testcontainers 120 | mysql 121 | test 122 | 123 | 124 | 125 | 126 | 127 | 128 | org.springframework.boot 129 | spring-boot-maven-plugin 130 | 131 | 132 | 133 | org.projectlombok 134 | lombok 135 | 136 | 137 | 138 | 139 | 140 | org.apache.maven.plugins 141 | maven-failsafe-plugin 142 | 143 | 144 | 145 | integration-test 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/RateLimitingApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class RateLimitingApiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(RateLimitingApiApplication.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/BypassRateLimit.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Annotation for excluding specific private API endpoints from rate limit 10 | * enforcement, allowing them to be accessed without restriction regardless of 11 | * the user's current rate limit plan. 12 | * 13 | * When applied to a controller method, requests to that method will not be 14 | * subject to rate limiting by the {@link com.behl.overseer.filter.RateLimitFilter}. 15 | * 16 | * @see com.behl.overseer.filter.RateLimitFilter 17 | */ 18 | @Target(ElementType.METHOD) 19 | @Retention(RetentionPolicy.RUNTIME) 20 | public @interface BypassRateLimit { 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/OpenApiConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | import io.swagger.v3.oas.models.Components; 8 | import io.swagger.v3.oas.models.OpenAPI; 9 | import io.swagger.v3.oas.models.info.Info; 10 | import io.swagger.v3.oas.models.security.SecurityRequirement; 11 | import io.swagger.v3.oas.models.security.SecurityScheme; 12 | import lombok.RequiredArgsConstructor; 13 | 14 | @Configuration 15 | @RequiredArgsConstructor 16 | @EnableConfigurationProperties(OpenApiConfigurationProperties.class) 17 | public class OpenApiConfiguration { 18 | 19 | private final OpenApiConfigurationProperties openApiConfigurationProperties; 20 | 21 | private static final String BEARER_AUTH_COMPONENT_NAME = "Bearer Authentication"; 22 | private static final String BEARER_AUTH_SCHEME = "Bearer"; 23 | 24 | @Bean 25 | public OpenAPI openApi() { 26 | final var properties = openApiConfigurationProperties.getOpenApi(); 27 | final var info = new Info() 28 | .version(properties.getApiVersion()) 29 | .title(properties.getTitle()) 30 | .description(properties.getDescription()); 31 | 32 | return new OpenAPI() 33 | .info(info) 34 | .components(new Components() 35 | .addSecuritySchemes(BEARER_AUTH_COMPONENT_NAME, 36 | new SecurityScheme() 37 | .type(SecurityScheme.Type.HTTP) 38 | .scheme(BEARER_AUTH_SCHEME))) 39 | .addSecurityItem(new SecurityRequirement() 40 | .addList(BEARER_AUTH_COMPONENT_NAME)); 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/OpenApiConfigurationProperties.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | 8 | @Getter 9 | @Setter 10 | @ConfigurationProperties(prefix = "com.behl.overseer") 11 | public class OpenApiConfigurationProperties { 12 | 13 | private OpenAPI openApi = new OpenAPI(); 14 | 15 | @Getter 16 | @Setter 17 | public class OpenAPI { 18 | 19 | /** 20 | * Determines whether Swagger v3 API documentation and related endpoints are 21 | * accessible bypassing Authentication and Authorization checks. Swagger 22 | * endpoints are restricted by default. 23 | * 24 | * Can be used in profile-specific configuration files to control 25 | * access based on current environments. 26 | */ 27 | private boolean enabled; 28 | 29 | private String title; 30 | private String apiVersion; 31 | private String description; 32 | 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/PublicEndpoint.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | /** 9 | * Annotation to declare API endpoints as public i.e non-secured, allowing then 10 | * to be accessed without a valid Authorization header in HTTP request. 11 | * 12 | * When applied to a controller method, requests to the method will be exempted 13 | * from authentication checks by the {@link com.behl.overseer.filter.JwtAuthenticationFilter} 14 | * 15 | * @see com.behl.overseer.configuration.SecurityConfiguration 16 | * @see com.behl.overseer.filter.JwtAuthenticationFilter 17 | */ 18 | @Target(ElementType.METHOD) 19 | @Retention(RetentionPolicy.RUNTIME) 20 | public @interface PublicEndpoint { 21 | 22 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/RedisConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import java.util.Optional; 4 | import java.util.UUID; 5 | 6 | import javax.cache.CacheManager; 7 | import javax.cache.Caching; 8 | 9 | import org.redisson.config.Config; 10 | import org.redisson.jcache.configuration.RedissonConfiguration; 11 | import org.springframework.boot.autoconfigure.data.redis.RedisProperties; 12 | import org.springframework.context.annotation.Bean; 13 | import org.springframework.context.annotation.Configuration; 14 | 15 | import io.github.bucket4j.distributed.proxy.ProxyManager; 16 | import io.github.bucket4j.grid.jcache.JCacheProxyManager; 17 | 18 | @Configuration 19 | public class RedisConfiguration { 20 | 21 | private static final String CACHE_NAME = "rate-limit"; 22 | 23 | @Bean(name = "rate-limit-cache-manager") 24 | public CacheManager cacheManager(final RedisProperties redisProperties) { 25 | final var cacheManager = Caching.getCachingProvider().getCacheManager(); 26 | final var isCacheCreated = Optional.ofNullable(cacheManager.getCache(CACHE_NAME)).isPresent(); 27 | 28 | if (Boolean.FALSE.equals(isCacheCreated)) { 29 | final var connectionUrl = String.format("redis://%s:%d", redisProperties.getHost(), redisProperties.getPort()); 30 | final var configuration = new Config(); 31 | configuration.useSingleServer().setPassword(redisProperties.getPassword()).setAddress(connectionUrl); 32 | 33 | cacheManager.createCache(CACHE_NAME, RedissonConfiguration.fromConfig(configuration)); 34 | } 35 | return cacheManager; 36 | } 37 | 38 | @Bean 39 | ProxyManager proxyManager(final CacheManager cacheManager) { 40 | return new JCacheProxyManager(cacheManager.getCache(CACHE_NAME)); 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/SecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.http.HttpMethod; 8 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 | import org.springframework.security.config.http.SessionCreationPolicy; 10 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 11 | import org.springframework.security.crypto.password.PasswordEncoder; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 14 | import org.springframework.web.cors.CorsConfiguration; 15 | import org.springframework.web.cors.CorsConfigurationSource; 16 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 17 | 18 | import com.behl.overseer.filter.JwtAuthenticationFilter; 19 | import com.behl.overseer.filter.RateLimitFilter; 20 | import com.behl.overseer.utility.ApiEndpointSecurityInspector; 21 | 22 | import lombok.RequiredArgsConstructor; 23 | import lombok.SneakyThrows; 24 | 25 | /** 26 | * Configuration class responsible for defining and configuring the security 27 | * settings for the application. It sets up the following components and 28 | * features: 29 | *
    30 | *
  • Configuration of non-secured public API endpoints.
  • 31 | *
  • Integration of custom JWT Auth filter into the security filter chain to 32 | * ensure that all requests to private API endpoints pass through the filter 33 | * for authentication verification.
  • 34 | *
  • Integration of custom Rate limiting filter into the security filter chain 35 | * to ensure private API endpoints are invoked by an authenticated user 36 | * within their corresponding {@link com.behl.overseer.entity.Plan}
  • 37 | *
38 | * 39 | * @see com.behl.overseer.filter.JwtAuthenticationFilter 40 | * @see com.behl.overseer.filter.RateLimitFilter 41 | * @see com.behl.overseer.utility.ApiEndpointSecurityInspector 42 | */ 43 | @Configuration 44 | @RequiredArgsConstructor 45 | public class SecurityConfiguration { 46 | 47 | private final RateLimitFilter rateLimitFilter; 48 | private final JwtAuthenticationFilter jwtAuthenticationFilter; 49 | private final ApiEndpointSecurityInspector apiEndpointSecurityInspector; 50 | 51 | @Bean 52 | @SneakyThrows 53 | public SecurityFilterChain configure(final HttpSecurity http) { 54 | http 55 | .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) 56 | .csrf(csrfConfigurer -> csrfConfigurer.disable()) 57 | .sessionManagement(sessionConfigurer -> sessionConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 58 | .authorizeHttpRequests(authManager -> { 59 | authManager 60 | .requestMatchers(HttpMethod.GET, apiEndpointSecurityInspector.getPublicGetEndpoints().toArray(String[]::new)).permitAll() 61 | .requestMatchers(HttpMethod.POST, apiEndpointSecurityInspector.getPublicPostEndpoints().toArray(String[]::new)).permitAll() 62 | .anyRequest().authenticated(); 63 | }) 64 | .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) 65 | .addFilterAfter(rateLimitFilter, JwtAuthenticationFilter.class); 66 | 67 | return http.build(); 68 | } 69 | 70 | @Bean 71 | public PasswordEncoder passwordEncoder() { 72 | return new BCryptPasswordEncoder(); 73 | } 74 | 75 | private CorsConfigurationSource corsConfigurationSource() { 76 | final var corsConfiguration = new CorsConfiguration(); 77 | corsConfiguration.setAllowedOrigins(List.of("*")); 78 | corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); 79 | corsConfiguration.setAllowedHeaders(List.of("Authorization", "Origin", "Content-Type", "Accept")); 80 | corsConfiguration.setExposedHeaders(List.of("Content-Type", "X-Rate-Limit-Retry-After-Seconds", "X-Rate-Limit-Remaining")); 81 | 82 | final var corsConfigurationSource = new UrlBasedCorsConfigurationSource(); 83 | corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration); 84 | return corsConfigurationSource; 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/configuration/TokenConfigurationProperties.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.configuration; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.validation.annotation.Validated; 5 | 6 | import jakarta.validation.constraints.NotBlank; 7 | import jakarta.validation.constraints.NotNull; 8 | import jakarta.validation.constraints.Pattern; 9 | import jakarta.validation.constraints.Positive; 10 | import lombok.Getter; 11 | import lombok.Setter; 12 | 13 | /** 14 | * Configuration properties controlling token generation, validation, and 15 | * expiration within the application. The configured values are referenced by 16 | * the application when generating/validating JWT tokens. 17 | * 18 | * @see com.behl.overseer.utility.JwtUtility 19 | */ 20 | @Getter 21 | @Setter 22 | @Validated 23 | @ConfigurationProperties(prefix = "com.behl.overseer.token") 24 | public class TokenConfigurationProperties { 25 | 26 | /** 27 | * The symmetric secret-key used for both signing and verifying the signature of 28 | * received access token(s) to ensure authenticity. 29 | * The configured value must be Base64 encoded. 30 | */ 31 | @NotBlank 32 | @Pattern(regexp = "^[a-zA-Z0-9+/]*={0,2}$", message = "Secret key must be Base64 encoded.") 33 | private String secretKey; 34 | 35 | /** 36 | * The validity period of JWT access token(s) in minutes, post which the token 37 | * expires and can no longer be used for authentication. 38 | */ 39 | @NotNull 40 | @Positive 41 | private Integer validity; 42 | 43 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/controller/AuthenticationController.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.controller; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.http.MediaType; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.PostMapping; 7 | import org.springframework.web.bind.annotation.RequestBody; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | import com.behl.overseer.configuration.PublicEndpoint; 12 | import com.behl.overseer.dto.ExceptionResponseDto; 13 | import com.behl.overseer.dto.TokenSuccessResponseDto; 14 | import com.behl.overseer.dto.UserCreationRequestDto; 15 | import com.behl.overseer.dto.UserLoginRequestDto; 16 | import com.behl.overseer.service.UserService; 17 | 18 | import io.swagger.v3.oas.annotations.Operation; 19 | import io.swagger.v3.oas.annotations.media.Content; 20 | import io.swagger.v3.oas.annotations.media.Schema; 21 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 22 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 23 | import io.swagger.v3.oas.annotations.tags.Tag; 24 | import jakarta.validation.Valid; 25 | import lombok.RequiredArgsConstructor; 26 | 27 | @RestController 28 | @RequiredArgsConstructor 29 | @RequestMapping("/api/v1") 30 | @Tag(name = "Authentication", description = "Endpoints for user account and authentication management") 31 | public class AuthenticationController { 32 | 33 | private final UserService userService; 34 | 35 | @PublicEndpoint 36 | @PostMapping(value = "/user", consumes = MediaType.APPLICATION_JSON_VALUE) 37 | @Operation(summary = "Creates a user record", description = "Creates a unique user record in the system corresponding to the provided information") 38 | @ApiResponses(value = { 39 | @ApiResponse(responseCode = "201", description = "User record created successfully", 40 | content = @Content(schema = @Schema(implementation = Void.class))), 41 | @ApiResponse(responseCode = "409", description = "User account with provided email-id already exists", 42 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))), 43 | @ApiResponse(responseCode = "404", description = "No plan exists in the system with provided-id", 44 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))), 45 | @ApiResponse(responseCode = "400", description = "Invalid request body", 46 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))) }) 47 | public ResponseEntity createUser(@Valid @RequestBody final UserCreationRequestDto userCreationRequest) { 48 | userService.create(userCreationRequest); 49 | return ResponseEntity.status(HttpStatus.CREATED).build(); 50 | } 51 | 52 | @PublicEndpoint 53 | @PostMapping(value = "/auth/login") 54 | @Operation(summary = "Validates user login credentials", description = "Validates user login credentials and returns access-token on successful authentication") 55 | @ApiResponses(value = { 56 | @ApiResponse(responseCode = "200", description = "Authentication successfull"), 57 | @ApiResponse(responseCode = "401", description = "Invalid credentials provided. Failed to authenticate user", 58 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))), 59 | @ApiResponse(responseCode = "400", description = "Invalid request body", 60 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))) }) 61 | public ResponseEntity login( 62 | @Valid @RequestBody final UserLoginRequestDto userLoginRequest) { 63 | final var response = userService.login(userLoginRequest); 64 | return ResponseEntity.ok(response); 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/controller/JokeController.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.controller; 2 | 3 | import org.springframework.http.MediaType; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | import org.springframework.web.bind.annotation.RestController; 8 | 9 | import com.behl.overseer.dto.ExceptionResponseDto; 10 | import com.behl.overseer.dto.JokeResponseDto; 11 | import com.behl.overseer.utility.JokeGenerator; 12 | 13 | import io.swagger.v3.oas.annotations.Operation; 14 | import io.swagger.v3.oas.annotations.headers.Header; 15 | import io.swagger.v3.oas.annotations.media.Content; 16 | import io.swagger.v3.oas.annotations.media.Schema; 17 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 18 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 19 | import io.swagger.v3.oas.annotations.tags.Tag; 20 | import lombok.RequiredArgsConstructor; 21 | 22 | @RestController 23 | @RequiredArgsConstructor 24 | @RequestMapping("/api/v1") 25 | @Tag(name = "Joke Generator", description = "Endpoint for generating random unfunny joke") 26 | public class JokeController { 27 | 28 | private final JokeGenerator jokeGenerator; 29 | 30 | @GetMapping(value = "/joke", produces = MediaType.APPLICATION_JSON_VALUE) 31 | @Operation(summary = "Generates a random unfunny joke") 32 | @ApiResponses(value = { 33 | @ApiResponse(responseCode = "200", description = "Successfully generated random unfunny joke", 34 | headers = @Header(name = "X-Rate-Limit-Remaining", description = "The number of remaining API invocations available with the user after processing the request.", required = true, 35 | schema = @Schema(type = "integer"))), 36 | @ApiResponse(responseCode = "429", description = "API rate limit exhausted", 37 | headers = @Header(name = "X-Rate-Limit-Retry-After-Seconds", description = "Wait period in seconds before the user can invoke the API endpoint", required = true, 38 | schema = @Schema(type = "integer")), 39 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))) }) 40 | public ResponseEntity generate() { 41 | final var response = jokeGenerator.generate(); 42 | return ResponseEntity.ok(response); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/controller/PlanController.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.controller; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.PutMapping; 10 | import org.springframework.web.bind.annotation.RequestBody; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | import com.behl.overseer.configuration.BypassRateLimit; 15 | import com.behl.overseer.configuration.PublicEndpoint; 16 | import com.behl.overseer.dto.ExceptionResponseDto; 17 | import com.behl.overseer.dto.PlanResponseDto; 18 | import com.behl.overseer.dto.PlanUpdationRequestDto; 19 | import com.behl.overseer.service.PlanService; 20 | 21 | import io.swagger.v3.oas.annotations.Operation; 22 | import io.swagger.v3.oas.annotations.media.Content; 23 | import io.swagger.v3.oas.annotations.media.Schema; 24 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 25 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 26 | import io.swagger.v3.oas.annotations.tags.Tag; 27 | import jakarta.validation.Valid; 28 | import lombok.RequiredArgsConstructor; 29 | 30 | @RestController 31 | @RequiredArgsConstructor 32 | @RequestMapping("/api/v1") 33 | @Tag(name = "Plan Management", description = "Endpoints for managing and retrieving available plan details") 34 | public class PlanController { 35 | 36 | private final PlanService planService; 37 | 38 | @PublicEndpoint 39 | @GetMapping(value = "/plan", produces = MediaType.APPLICATION_JSON_VALUE) 40 | @Operation(summary = "Retrieves all available plans", description = "Retrieves the list of available plans in the system") 41 | @ApiResponse(responseCode = "200", description = "Plans retrieved successfully") 42 | public ResponseEntity> retrieve() { 43 | return ResponseEntity.ok(planService.retrieve()); 44 | } 45 | 46 | @BypassRateLimit 47 | @PutMapping(value = "/plan") 48 | @Operation(summary = "Update user plan", description = "Updates an existing plan of an authenticated user") 49 | @ApiResponses(value = { 50 | @ApiResponse(responseCode = "200", description = "Plan updated successfully", 51 | content = @Content(schema = @Schema(implementation = Void.class))), 52 | @ApiResponse(responseCode = "404", description = "No plan exists in the system with provided-id", 53 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))), 54 | @ApiResponse(responseCode = "429", description = "API rate limit exhausted", 55 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class))), 56 | @ApiResponse(responseCode = "400", description = "Invalid request body", 57 | content = @Content(schema = @Schema(implementation = ExceptionResponseDto.class)))}) 58 | public ResponseEntity update(@Valid @RequestBody final PlanUpdationRequestDto planUpdationRequest) { 59 | planService.update(planUpdationRequest); 60 | return ResponseEntity.ok().build(); 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/ExceptionResponseDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 4 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 5 | 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.Getter; 8 | import lombok.Setter; 9 | 10 | @Getter 11 | @Setter 12 | @Schema(title = "Error", accessMode = Schema.AccessMode.READ_ONLY) 13 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 14 | public class ExceptionResponseDto { 15 | 16 | private String status; 17 | private T description; 18 | 19 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/JokeResponseDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 4 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 5 | 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.extern.jackson.Jacksonized; 10 | 11 | @Getter 12 | @Builder 13 | @Jacksonized 14 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 15 | @Schema(title = "Joke", accessMode = Schema.AccessMode.READ_ONLY) 16 | public class JokeResponseDto { 17 | 18 | private String joke; 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/PlanResponseDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import java.util.UUID; 4 | 5 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 6 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 7 | 8 | import io.swagger.v3.oas.annotations.media.Schema; 9 | import lombok.Builder; 10 | import lombok.Getter; 11 | import lombok.extern.jackson.Jacksonized; 12 | 13 | @Getter 14 | @Builder 15 | @Jacksonized 16 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 17 | @Schema(title = "Plan", accessMode = Schema.AccessMode.READ_ONLY) 18 | public class PlanResponseDto { 19 | 20 | private UUID id; 21 | private String name; 22 | private Integer limitPerHour; 23 | 24 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/PlanUpdationRequestDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import java.util.UUID; 4 | 5 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 6 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 7 | 8 | import io.swagger.v3.oas.annotations.media.Schema; 9 | import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; 10 | import jakarta.validation.constraints.NotNull; 11 | import lombok.Getter; 12 | import lombok.Setter; 13 | 14 | @Getter 15 | @Setter 16 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 17 | @Schema(title = "PlanUpdationRequest", accessMode = Schema.AccessMode.WRITE_ONLY) 18 | public class PlanUpdationRequestDto { 19 | 20 | @NotNull 21 | @Schema(requiredMode = RequiredMode.REQUIRED, description = "plan to be attached with user record") 22 | private UUID planId; 23 | 24 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/TokenSuccessResponseDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 4 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 5 | 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.extern.jackson.Jacksonized; 10 | 11 | @Getter 12 | @Builder 13 | @Jacksonized 14 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 15 | @Schema(title = "TokenSuccessResponse", accessMode = Schema.AccessMode.READ_ONLY) 16 | public class TokenSuccessResponseDto { 17 | 18 | private String accessToken; 19 | 20 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/UserCreationRequestDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import java.util.UUID; 4 | 5 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 6 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 7 | 8 | import io.swagger.v3.oas.annotations.media.Schema; 9 | import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; 10 | import jakarta.validation.constraints.Email; 11 | import jakarta.validation.constraints.NotBlank; 12 | import jakarta.validation.constraints.NotNull; 13 | import lombok.Getter; 14 | import lombok.Setter; 15 | 16 | @Getter 17 | @Setter 18 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 19 | @Schema(title = "UserCreationRequest", accessMode = Schema.AccessMode.WRITE_ONLY) 20 | public class UserCreationRequestDto { 21 | 22 | @NotBlank(message = "email-id must not be empty") 23 | @Email(message = "email-id must be of valid format") 24 | @Schema(requiredMode = RequiredMode.REQUIRED, description = "email-id of user", example = "hardik.behl7444@gmail.com") 25 | private String emailId; 26 | 27 | @NotBlank(message = "password must not be empty") 28 | @Schema(requiredMode = RequiredMode.REQUIRED, description = "secure password to enable user login", example = "somethingSecure") 29 | private String password; 30 | 31 | @NotNull 32 | @Schema(requiredMode = RequiredMode.REQUIRED, description = "plan to be attached with new user record") 33 | private UUID planId; 34 | 35 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/dto/UserLoginRequestDto.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.dto; 2 | 3 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 4 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 5 | 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import io.swagger.v3.oas.annotations.media.Schema.RequiredMode; 8 | import jakarta.validation.constraints.Email; 9 | import jakarta.validation.constraints.NotBlank; 10 | import lombok.Getter; 11 | import lombok.Setter; 12 | 13 | @Getter 14 | @Setter 15 | @JsonNaming(value = PropertyNamingStrategies.UpperCamelCaseStrategy.class) 16 | @Schema(title = "UserLoginRequest", accessMode = Schema.AccessMode.WRITE_ONLY) 17 | public class UserLoginRequestDto { 18 | 19 | @NotBlank(message = "email-id must not be empty") 20 | @Email(message = "email-id must be of valid format") 21 | @Schema(requiredMode = RequiredMode.REQUIRED, example = "hardik.behl7444@gmail.com", description = "email-id associated with user account already created in the system") 22 | private String emailId; 23 | 24 | @NotBlank(message = "password must not be empty") 25 | @Schema(requiredMode = RequiredMode.REQUIRED, example = "somethingSecure", description = "password corresponding to provided email-id") 26 | private String password; 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/entity/Plan.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.entity; 2 | 3 | import java.time.LocalDateTime; 4 | import java.time.ZoneOffset; 5 | import java.util.UUID; 6 | 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.Id; 10 | import jakarta.persistence.PrePersist; 11 | import jakarta.persistence.PreUpdate; 12 | import jakarta.persistence.Table; 13 | import lombok.Getter; 14 | 15 | @Getter 16 | @Entity 17 | @Table(name = "plans") 18 | public class Plan { 19 | 20 | @Id 21 | @Column(name = "id", nullable = false, unique = true) 22 | private UUID id; 23 | 24 | @Column(name = "name", nullable = false, unique = true) 25 | private String name; 26 | 27 | @Column(name = "limit_per_hour", nullable = false, unique = true) 28 | private Integer limitPerHour; 29 | 30 | @Column(name = "created_at", nullable = false) 31 | private LocalDateTime createdAt; 32 | 33 | @Column(name = "updated_at", nullable = false) 34 | private LocalDateTime updatedAt; 35 | 36 | @PrePersist 37 | void onCreate() { 38 | this.id = UUID.randomUUID(); 39 | this.createdAt = LocalDateTime.now(ZoneOffset.UTC); 40 | this.updatedAt = LocalDateTime.now(ZoneOffset.UTC); 41 | } 42 | 43 | @PreUpdate 44 | void onUpdate() { 45 | this.updatedAt = LocalDateTime.now(ZoneOffset.UTC); 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/entity/User.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.entity; 2 | 3 | import java.time.LocalDateTime; 4 | import java.time.ZoneOffset; 5 | import java.util.UUID; 6 | 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.Id; 10 | import jakarta.persistence.PrePersist; 11 | import jakarta.persistence.Table; 12 | import lombok.AccessLevel; 13 | import lombok.Getter; 14 | import lombok.Setter; 15 | 16 | @Getter 17 | @Setter 18 | @Entity 19 | @Table(name = "users") 20 | public class User { 21 | 22 | @Id 23 | @Setter(AccessLevel.NONE) 24 | @Column(name = "id", nullable = false, unique = true) 25 | private UUID id; 26 | 27 | @Column(name = "email_id", nullable = false) 28 | private String emailId; 29 | 30 | @Column(name = "password", nullable = false) 31 | private String password; 32 | 33 | @Setter(AccessLevel.NONE) 34 | @Column(name = "created_at", nullable = false) 35 | private LocalDateTime createdAt; 36 | 37 | @PrePersist 38 | void onCreate() { 39 | this.id = UUID.randomUUID(); 40 | this.createdAt = LocalDateTime.now(ZoneOffset.UTC); 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/entity/UserPlanMapping.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.entity; 2 | 3 | import java.time.LocalDateTime; 4 | import java.time.ZoneOffset; 5 | import java.util.UUID; 6 | 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.FetchType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.JoinColumn; 12 | import jakarta.persistence.ManyToOne; 13 | import jakarta.persistence.PrePersist; 14 | import jakarta.persistence.PreUpdate; 15 | import jakarta.persistence.Table; 16 | import lombok.AccessLevel; 17 | import lombok.Getter; 18 | import lombok.Setter; 19 | import lombok.ToString; 20 | 21 | @Getter 22 | @Setter 23 | @Entity 24 | @ToString 25 | @Table(name = "user_plan_mappings") 26 | public class UserPlanMapping { 27 | 28 | @Id 29 | @Setter(AccessLevel.NONE) 30 | @Column(name = "id", nullable = false, unique = true) 31 | private UUID id; 32 | 33 | @Column(name = "user_id", nullable = true) 34 | private UUID userId; 35 | 36 | @Setter(AccessLevel.NONE) 37 | @ManyToOne(fetch = FetchType.EAGER, optional = false) 38 | @JoinColumn(name = "user_id", nullable = true, insertable = false, updatable = false) 39 | private User user; 40 | 41 | @Column(name = "plan_id", nullable = true) 42 | private UUID planId; 43 | 44 | @Setter(AccessLevel.NONE) 45 | @ManyToOne(fetch = FetchType.EAGER, optional = false) 46 | @JoinColumn(name = "plan_id", nullable = true, insertable = false, updatable = false) 47 | private Plan plan; 48 | 49 | @Column(name = "is_active", nullable = false) 50 | private Boolean isActive; 51 | 52 | @Setter(AccessLevel.NONE) 53 | @Column(name = "created_at", nullable = false) 54 | private LocalDateTime createdAt; 55 | 56 | @Setter(AccessLevel.NONE) 57 | @Column(name = "updated_at", nullable = false) 58 | private LocalDateTime updatedAt; 59 | 60 | @PrePersist 61 | void onCreate() { 62 | this.id = UUID.randomUUID(); 63 | this.isActive = Boolean.TRUE; 64 | this.createdAt = LocalDateTime.now(ZoneOffset.UTC); 65 | this.updatedAt = LocalDateTime.now(ZoneOffset.UTC); 66 | } 67 | 68 | @PreUpdate 69 | void onUpdate() { 70 | this.updatedAt = LocalDateTime.now(ZoneOffset.UTC); 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/exception/AccountAlreadyExistsException.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.server.ResponseStatusException; 5 | 6 | import lombok.NonNull; 7 | 8 | public class AccountAlreadyExistsException extends ResponseStatusException { 9 | 10 | private static final long serialVersionUID = 7439642984069939024L; 11 | 12 | public AccountAlreadyExistsException(@NonNull final String reason) { 13 | super(HttpStatus.CONFLICT, reason); 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/exception/ExceptionResponseHandler.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.exception; 2 | 3 | import java.util.List; 4 | import java.util.concurrent.atomic.AtomicReference; 5 | import java.util.stream.Collectors; 6 | 7 | import org.springframework.http.HttpHeaders; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.HttpStatusCode; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.http.converter.HttpMessageNotReadableException; 12 | import org.springframework.web.bind.MethodArgumentNotValidException; 13 | import org.springframework.web.bind.annotation.ControllerAdvice; 14 | import org.springframework.web.bind.annotation.ExceptionHandler; 15 | import org.springframework.web.bind.annotation.ResponseBody; 16 | import org.springframework.web.context.request.WebRequest; 17 | import org.springframework.web.server.ResponseStatusException; 18 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 19 | 20 | import com.behl.overseer.dto.ExceptionResponseDto; 21 | import com.fasterxml.jackson.databind.JsonMappingException.Reference; 22 | import com.fasterxml.jackson.databind.exc.InvalidFormatException; 23 | import com.fasterxml.jackson.databind.exc.MismatchedInputException; 24 | import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; 25 | 26 | import lombok.NonNull; 27 | import lombok.extern.slf4j.Slf4j; 28 | 29 | @Slf4j 30 | @ControllerAdvice 31 | public class ExceptionResponseHandler extends ResponseEntityExceptionHandler { 32 | 33 | private static final String NOT_READABLE_REQUEST_ERROR_MESSAGE = "The request is malformed. Ensure the JSON structure is correct."; 34 | 35 | @ResponseBody 36 | @ExceptionHandler(ResponseStatusException.class) 37 | public ResponseEntity> responseStatusExceptionHandler(final ResponseStatusException exception) { 38 | logException(exception); 39 | final var exceptionResponse = new ExceptionResponseDto(); 40 | exceptionResponse.setStatus(exception.getStatusCode().toString()); 41 | exceptionResponse.setDescription(exception.getReason()); 42 | return ResponseEntity.status(exception.getStatusCode()).body(exceptionResponse); 43 | } 44 | 45 | @Override 46 | protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException exception, 47 | HttpHeaders headers, HttpStatusCode status, WebRequest request) { 48 | logException(exception); 49 | final var fieldErrors = exception.getBindingResult().getFieldErrors(); 50 | final var description = fieldErrors.stream().map(fieldError -> fieldError.getDefaultMessage()).collect(Collectors.toList()); 51 | 52 | final var exceptionResponse = new ExceptionResponseDto>(); 53 | exceptionResponse.setStatus(HttpStatus.BAD_REQUEST.toString()); 54 | exceptionResponse.setDescription(description); 55 | 56 | return ResponseEntity.badRequest().body(exceptionResponse); 57 | } 58 | 59 | @Override 60 | protected ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException exception, 61 | HttpHeaders headers, HttpStatusCode status, WebRequest request) { 62 | logException(exception); 63 | final var exceptionResponse = new ExceptionResponseDto>(); 64 | exceptionResponse.setStatus(HttpStatus.BAD_REQUEST.toString()); 65 | final var description = new AtomicReference(NOT_READABLE_REQUEST_ERROR_MESSAGE); 66 | 67 | if (exception.getCause() instanceof InvalidFormatException invalidFormatException) { 68 | invalidFormatException.getPath().stream().map(Reference::getFieldName).findFirst().ifPresent(fieldName -> { 69 | final var invalidValue = invalidFormatException.getValue(); 70 | final var errorMessage = String.format("Invalid value '%s' for '%s'.", invalidValue, fieldName); 71 | description.set(errorMessage); 72 | }); 73 | } else if (exception.getCause() instanceof UnrecognizedPropertyException unrecognizedPropertyException) { 74 | unrecognizedPropertyException.getPath().stream().map(Reference::getFieldName).findFirst().ifPresent(fieldName -> { 75 | final var errorMessage = String.format("Unrecognized property '%s' detected.", fieldName); 76 | description.set(errorMessage); 77 | }); 78 | } else if (exception.getCause() instanceof MismatchedInputException mismatchedInputException) { 79 | mismatchedInputException.getPath().stream().map(Reference::getFieldName).findFirst().ifPresent(fieldName -> { 80 | final var errorMessage = String.format("Invalid data type for field '%s'.", fieldName); 81 | description.set(errorMessage); 82 | }); 83 | } 84 | 85 | exceptionResponse.setDescription(description); 86 | return ResponseEntity.badRequest().body(exceptionResponse); 87 | } 88 | 89 | @ResponseBody 90 | @ExceptionHandler(Exception.class) 91 | public ResponseEntity serverExceptionHandler(final Exception exception) { 92 | logException(exception); 93 | final var exceptionResponse = new ExceptionResponseDto(); 94 | exceptionResponse.setStatus(HttpStatus.NOT_IMPLEMENTED.toString()); 95 | exceptionResponse.setDescription("Something went wrong."); 96 | return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(exceptionResponse); 97 | } 98 | 99 | private void logException(final @NonNull Exception exception) { 100 | log.error("Exception encountered: {}", exception.getMessage(), exception); 101 | } 102 | 103 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/exception/InvalidLoginCredentialsException.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.server.ResponseStatusException; 5 | 6 | public class InvalidLoginCredentialsException extends ResponseStatusException { 7 | 8 | private static final long serialVersionUID = 7439642984069939024L; 9 | private static final String DEFAULT_MESSAGE = "Invalid login credentials provided"; 10 | 11 | public InvalidLoginCredentialsException() { 12 | super(HttpStatus.UNAUTHORIZED, DEFAULT_MESSAGE); 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/exception/InvalidPlanException.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.server.ResponseStatusException; 5 | 6 | import lombok.NonNull; 7 | 8 | public class InvalidPlanException extends ResponseStatusException { 9 | 10 | private static final long serialVersionUID = 4506094675559975006L; 11 | 12 | public InvalidPlanException(@NonNull final String reason) { 13 | super(HttpStatus.NOT_FOUND, reason); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/filter/JwtAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.filter; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.springframework.http.HttpStatus; 5 | import org.springframework.http.MediaType; 6 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; 9 | import org.springframework.stereotype.Component; 10 | import org.springframework.web.filter.OncePerRequestFilter; 11 | 12 | import com.behl.overseer.dto.ExceptionResponseDto; 13 | import com.behl.overseer.utility.ApiEndpointSecurityInspector; 14 | import com.behl.overseer.utility.JwtUtility; 15 | import com.fasterxml.jackson.databind.ObjectMapper; 16 | 17 | import jakarta.servlet.FilterChain; 18 | import jakarta.servlet.http.HttpServletRequest; 19 | import jakarta.servlet.http.HttpServletResponse; 20 | import lombok.RequiredArgsConstructor; 21 | import lombok.SneakyThrows; 22 | 23 | /** 24 | * JwtAuthenticationFilter is a custom filter registered with the spring 25 | * security filter chain and works in conjunction with the security 26 | * configuration, as defined in {@link com.behl.overseer.configuration.SecurityConfiguration}. 27 | * 28 | * It is responsible for verifying the authenticity of incoming HTTP requests to 29 | * secured API endpoints by examining JWT token in the request header, verifying 30 | * it's signature, expiration. 31 | * If authentication is successful, the filter populates the security context with 32 | * the user's unique identifier which can be referenced by the application later. 33 | * 34 | * This filter is only executed when a secure API endpoint in invoked, and is skipped 35 | * if the incoming request is destined to a non-secured public API endpoint. 36 | * 37 | * @see com.behl.overseer.configuration.SecurityConfiguration 38 | * @see com.behl.overseer.utility.ApiEndpointSecurityInspector 39 | * @see com.behl.overseer.utility.JwtUtility 40 | */ 41 | @Component 42 | @RequiredArgsConstructor 43 | public class JwtAuthenticationFilter extends OncePerRequestFilter { 44 | 45 | private final ObjectMapper objectMapper; 46 | private final JwtUtility jwtUtility; 47 | private final ApiEndpointSecurityInspector apiEndpointSecurityInspector; 48 | 49 | private static final String AUTHORIZATION_HEADER = "Authorization"; 50 | private static final String BEARER_PREFIX = "Bearer "; 51 | private static final String MISSING_TOKEN_ERROR_MESSAGE = "Authentication failure: Token missing, invalid or expired"; 52 | 53 | @Override 54 | @SneakyThrows 55 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { 56 | final var unsecuredApiBeingInvoked = apiEndpointSecurityInspector.isUnsecureRequest(request); 57 | 58 | if (Boolean.FALSE.equals(unsecuredApiBeingInvoked)) { 59 | final var authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); 60 | 61 | if (StringUtils.isNotEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX) ) { 62 | final var token = authorizationHeader.replace(BEARER_PREFIX, StringUtils.EMPTY); 63 | 64 | final var userId = jwtUtility.getUserId(token); 65 | final var authentication = new UsernamePasswordAuthenticationToken(userId, null, null); 66 | authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); 67 | SecurityContextHolder.getContext().setAuthentication(authentication); 68 | } else { 69 | setAuthErrorDetails(response); 70 | return; 71 | } 72 | } 73 | filterChain.doFilter(request, response); 74 | } 75 | 76 | /** 77 | * Sets the authentication error details in the HTTP response. 78 | * 79 | * @param response instance of HttpServletResponse to which error response will be set. 80 | */ 81 | @SneakyThrows 82 | private void setAuthErrorDetails(HttpServletResponse response) { 83 | response.setStatus(HttpStatus.UNAUTHORIZED.value()); 84 | response.setContentType(MediaType.APPLICATION_JSON_VALUE); 85 | final var errorResponse = prepareErrorResponseBody(); 86 | response.getWriter().write(errorResponse); 87 | } 88 | 89 | /** 90 | * Returns a JSON representation of the invalid token error response body. 91 | */ 92 | @SneakyThrows 93 | private String prepareErrorResponseBody() { 94 | final var exceptionResponse = new ExceptionResponseDto(); 95 | exceptionResponse.setStatus(HttpStatus.UNAUTHORIZED.toString()); 96 | exceptionResponse.setDescription(MISSING_TOKEN_ERROR_MESSAGE); 97 | return objectMapper.writeValueAsString(exceptionResponse); 98 | } 99 | 100 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/filter/RateLimitFilter.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.filter; 2 | 3 | import java.util.concurrent.TimeUnit; 4 | 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.web.filter.OncePerRequestFilter; 9 | import org.springframework.web.method.HandlerMethod; 10 | import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; 11 | 12 | import com.behl.overseer.configuration.BypassRateLimit; 13 | import com.behl.overseer.dto.ExceptionResponseDto; 14 | import com.behl.overseer.service.RateLimitingService; 15 | import com.behl.overseer.utility.ApiEndpointSecurityInspector; 16 | import com.behl.overseer.utility.AuthenticatedUserIdProvider; 17 | import com.fasterxml.jackson.databind.ObjectMapper; 18 | 19 | import io.github.bucket4j.ConsumptionProbe; 20 | import jakarta.servlet.FilterChain; 21 | import jakarta.servlet.http.HttpServletRequest; 22 | import jakarta.servlet.http.HttpServletResponse; 23 | import lombok.RequiredArgsConstructor; 24 | import lombok.SneakyThrows; 25 | 26 | /** 27 | * RateLimitFilter is a custom filter registered with the spring security filter 28 | * chain and works in conjunction with the security configuration, as defined in 29 | * {@link com.behl.overseer.configuration.SecurityConfiguration}. As per 30 | * established configuration, this filter is executed after evaluation of 31 | * {@link com.behl.overseer.filter.JwtAuthenticationFilter} 32 | * 33 | * This filter is responsible for enforcing rate limit on secured 34 | * API endpoint(s) corresponding to the user's current plan. It intercepts 35 | * incoming HTTP requests and evaluates whether the user has exhausted the 36 | * limit enforced. 37 | * If the limit is exceeded, an error response indicating that the 38 | * request limit linked to the user's current plan has been exhausted 39 | * is returned back to the client. 40 | * 41 | * This filter is only executed when a secure API endpoint in invoked, and is skipped 42 | * if the incoming request is destined to a non-secured public API endpoint. 43 | * 44 | * Additionally, the rate limit enforcement can be bypassed for specific private 45 | * API endpoints by annotating the corresponding controller methods with 46 | * {@link com.behl.overseer.configuration.BypassRateLimit} annotation. 47 | * 48 | * @see com.behl.overseer.configuration.BypassRateLimit 49 | * @see com.behl.overseer.service.RateLimitingService 50 | * @see com.behl.overseer.utility.ApiEndpointSecurityInspector 51 | */ 52 | @Component 53 | @RequiredArgsConstructor 54 | public class RateLimitFilter extends OncePerRequestFilter { 55 | 56 | private final ObjectMapper objectMapper; 57 | private final RateLimitingService rateLimitingService; 58 | private final RequestMappingHandlerMapping requestHandlerMapping; 59 | private final AuthenticatedUserIdProvider authenticatedUserIdProvider; 60 | private final ApiEndpointSecurityInspector apiEndpointSecurityInspector; 61 | 62 | private static final String RATE_LIMIT_ERROR_MESSAGE = "API request limit linked to your current plan has been exhausted."; 63 | private static final HttpStatus RATE_LIMIT_ERROR_STATUS = HttpStatus.TOO_MANY_REQUESTS; 64 | 65 | @Override 66 | @SneakyThrows 67 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { 68 | final var unsecuredApiBeingInvoked = apiEndpointSecurityInspector.isUnsecureRequest(request); 69 | 70 | if (Boolean.FALSE.equals(unsecuredApiBeingInvoked) && authenticatedUserIdProvider.isAvailable()) { 71 | final var isRequestBypassed = isBypassed(request); 72 | 73 | if (Boolean.FALSE.equals(isRequestBypassed)) { 74 | final var userId = authenticatedUserIdProvider.getUserId(); 75 | final var bucket = rateLimitingService.getBucket(userId); 76 | final var consumptionProbe = bucket.tryConsumeAndReturnRemaining(1); 77 | final var isConsumptionPassed = consumptionProbe.isConsumed(); 78 | 79 | if (Boolean.FALSE.equals(isConsumptionPassed)) { 80 | setRateLimitErrorDetails(response, consumptionProbe); 81 | return; 82 | } 83 | 84 | final var remainingTokens = consumptionProbe.getRemainingTokens(); 85 | response.setHeader("X-Rate-Limit-Remaining", String.valueOf(remainingTokens)); 86 | } 87 | } 88 | filterChain.doFilter(request, response); 89 | } 90 | 91 | /** 92 | * Checks if the controller method corresponding to current request is annotated 93 | * with {@link BypassRateLimit} annotation, indicating that rate limit 94 | * enforcement should be bypassed. 95 | * 96 | * @param request HttpServletRequest representing the incoming HTTP request 97 | * @return {@code true} if the request is to be bypassed, {@code false} otherwise 98 | */ 99 | @SneakyThrows 100 | private boolean isBypassed(HttpServletRequest request) { 101 | var handlerChain = requestHandlerMapping.getHandler(request); 102 | if (handlerChain != null && handlerChain.getHandler() instanceof HandlerMethod handlerMethod) { 103 | return handlerMethod.getMethod().isAnnotationPresent(BypassRateLimit.class); 104 | } 105 | return Boolean.FALSE; 106 | } 107 | 108 | /** 109 | * Sets the rate limit error details in the HTTP response. This method is 110 | * invoked when the user has exceeded their configured rate limit for API 111 | * requests. 112 | * 113 | * @param response instance of HttpServletResponse to which the rate limit error response will be set. 114 | * @param consumptionProbe ConsumptionProbe object representing the rate limit consumption information. 115 | */ 116 | @SneakyThrows 117 | private void setRateLimitErrorDetails(HttpServletResponse response, final ConsumptionProbe consumptionProbe) { 118 | response.setStatus(RATE_LIMIT_ERROR_STATUS.value()); 119 | response.setContentType(MediaType.APPLICATION_JSON_VALUE); 120 | 121 | final var waitPeriod = TimeUnit.NANOSECONDS.toSeconds(consumptionProbe.getNanosToWaitForRefill()); 122 | response.setHeader("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitPeriod)); 123 | 124 | final var errorResponse = prepareErrorResponseBody(); 125 | response.getWriter().write(errorResponse); 126 | } 127 | 128 | /** 129 | * Returns a JSON representation of the rate limit exhaustion error response 130 | * body. 131 | */ 132 | @SneakyThrows 133 | private String prepareErrorResponseBody() { 134 | final var exceptionResponse = new ExceptionResponseDto(); 135 | exceptionResponse.setStatus(RATE_LIMIT_ERROR_STATUS.toString()); 136 | exceptionResponse.setDescription(RATE_LIMIT_ERROR_MESSAGE); 137 | return objectMapper.writeValueAsString(exceptionResponse); 138 | } 139 | 140 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/repository/PlanRepository.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.repository; 2 | 3 | import java.util.UUID; 4 | 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import com.behl.overseer.entity.Plan; 9 | 10 | @Repository 11 | public interface PlanRepository extends JpaRepository { 12 | 13 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/repository/UserPlanMappingRepository.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.repository; 2 | 3 | import java.util.UUID; 4 | 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.Modifying; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.stereotype.Repository; 9 | 10 | import com.behl.overseer.entity.UserPlanMapping; 11 | 12 | import jakarta.transaction.Transactional; 13 | 14 | @Repository 15 | public interface UserPlanMappingRepository extends JpaRepository { 16 | 17 | /** 18 | * Deactivates the current plan for the specified user. 19 | * 20 | * @param userId The unique identifier of the user 21 | */ 22 | @Transactional 23 | @Modifying(clearAutomatically = true) 24 | @Query(nativeQuery = true, value = """ 25 | UPDATE user_plan_mappings 26 | SET is_active = false 27 | WHERE user_id = ?1 and is_active = true 28 | """) 29 | void deactivateCurrentPlan(final UUID userId); 30 | 31 | /** 32 | * Retrieves the active plan for the specified user. 33 | * 34 | * @param userId The unique identifier of the user 35 | * @return The active plan mapping for the user 36 | */ 37 | @Query(nativeQuery = true, value = """ 38 | SELECT * FROM user_plan_mappings 39 | WHERE user_id = ?1 AND is_active = true 40 | """) 41 | UserPlanMapping getActivePlan(final UUID userId); 42 | 43 | /** 44 | * Checks if the specified plan is active for the given user. 45 | * 46 | * @param userId The unique identifier of the user 47 | * @param planId The unique identifier of the plan 48 | * @return true if the plan is active for the user, false otherwise 49 | */ 50 | @Query(value = """ 51 | SELECT COUNT(id) = 1 FROM UserPlanMapping 52 | WHERE isActive = true 53 | AND userId = ?1 54 | AND planId = ?2 55 | """) 56 | boolean isActivePlan(final UUID userId, final UUID planId); 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/repository/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.repository; 2 | 3 | import java.util.Optional; 4 | import java.util.UUID; 5 | 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import com.behl.overseer.entity.User; 10 | 11 | @Repository 12 | public interface UserRepository extends JpaRepository { 13 | 14 | Boolean existsByEmailId(final String emailId); 15 | 16 | Optional findByEmailId(final String emailId); 17 | 18 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/service/PlanService.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import java.util.List; 4 | 5 | import org.springframework.stereotype.Service; 6 | 7 | import com.behl.overseer.dto.PlanResponseDto; 8 | import com.behl.overseer.dto.PlanUpdationRequestDto; 9 | import com.behl.overseer.entity.UserPlanMapping; 10 | import com.behl.overseer.exception.InvalidPlanException; 11 | import com.behl.overseer.repository.PlanRepository; 12 | import com.behl.overseer.repository.UserPlanMappingRepository; 13 | import com.behl.overseer.utility.AuthenticatedUserIdProvider; 14 | 15 | import lombok.NonNull; 16 | import lombok.RequiredArgsConstructor; 17 | 18 | @Service 19 | @RequiredArgsConstructor 20 | public class PlanService { 21 | 22 | private final PlanRepository planRepository; 23 | private final RateLimitingService rateLimitingService; 24 | private final UserPlanMappingRepository userPlanMappingRepository; 25 | private final AuthenticatedUserIdProvider authenticatedUserIdProvider; 26 | 27 | /** 28 | * Updates the subscription plan for a user and deactivates their current plan 29 | * in the system. The rate-limit corresponding to the previous plan is cleared 30 | * on successful plan updation. 31 | * 32 | * If the provided plan-id to update matches the user's current plan-id, then no 33 | * changes in the datasource is performed and method execution is halted. 34 | * 35 | * @param planUpdationRequest containing user's new plan details. 36 | * @throws IllegalArgumentException if provided argument is null. 37 | * @throws InvalidPlanException if no plan exists with provided-id. 38 | */ 39 | public void update(@NonNull final PlanUpdationRequestDto planUpdationRequest) { 40 | final var planId = planUpdationRequest.getPlanId(); 41 | final var isPlanIdValid = planRepository.existsById(planId); 42 | if (Boolean.FALSE.equals(isPlanIdValid)) { 43 | throw new InvalidPlanException("No plan exists in the system with provided-id"); 44 | } 45 | 46 | final var userId = authenticatedUserIdProvider.getUserId(); 47 | final var isExistingUserPlan = userPlanMappingRepository.isActivePlan(userId, planId); 48 | if (Boolean.TRUE.equals(isExistingUserPlan)) { 49 | return; 50 | } 51 | 52 | userPlanMappingRepository.deactivateCurrentPlan(userId); 53 | 54 | final var newPlan = new UserPlanMapping(); 55 | newPlan.setUserId(userId); 56 | newPlan.setPlanId(planId); 57 | userPlanMappingRepository.save(newPlan); 58 | 59 | rateLimitingService.reset(userId); 60 | } 61 | 62 | /** 63 | * Retrieves all available subscription plans. 64 | * 65 | * @return List of PlanResponseDto containing details of each available plan. 66 | */ 67 | public List retrieve() { 68 | return planRepository.findAll() 69 | .stream() 70 | .map(plan -> PlanResponseDto.builder() 71 | .id(plan.getId()) 72 | .name(plan.getName()) 73 | .limitPerHour(plan.getLimitPerHour()) 74 | .build()) 75 | .toList(); 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/service/RateLimitingService.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import java.time.Duration; 4 | import java.util.UUID; 5 | 6 | import org.springframework.stereotype.Service; 7 | 8 | import com.behl.overseer.repository.UserPlanMappingRepository; 9 | 10 | import io.github.bucket4j.Bucket; 11 | import io.github.bucket4j.BucketConfiguration; 12 | import io.github.bucket4j.distributed.proxy.ProxyManager; 13 | import lombok.NonNull; 14 | import lombok.RequiredArgsConstructor; 15 | 16 | @Service 17 | @RequiredArgsConstructor 18 | public class RateLimitingService { 19 | 20 | private final ProxyManager proxyManager; 21 | private final UserPlanMappingRepository userPlanMappingRepository; 22 | 23 | /** 24 | * Retrieves the stored rate-limiting bucket for the specified user. If no 25 | * bucket is found for the user, a new one is created and stored in the 26 | * provisioned cache based on the user's current subscription plan. 27 | * 28 | * @param userId unique identifier of the user. 29 | * @return The rate-limiting {@link Bucket} associated with the user. 30 | * @throws IllegalArgumentException if provided argument is null. 31 | */ 32 | public Bucket getBucket(@NonNull final UUID userId) { 33 | return proxyManager.builder().build(userId, () -> createBucketConfiguration(userId)); 34 | } 35 | 36 | /** 37 | * Resets the rate limiting for the specified user-id. 38 | * 39 | * @param userId unique identifier of the user. 40 | * @throws IllegalArgumentException if provided argument is null. 41 | */ 42 | public void reset(@NonNull final UUID userId) { 43 | proxyManager.removeProxy(userId); 44 | } 45 | 46 | /** 47 | * Constructs an instance of {@link BucketConfiguration} corresponding to the 48 | * user's active plan which enforce the allowed rate-limit of API invocation. 49 | * 50 | * @param userId The unique identifier of the user. 51 | * @return The bucket configuration for rate limiting based on the user's active plan. 52 | * @throws IllegalArgumentException if provided argument is null. 53 | */ 54 | private BucketConfiguration createBucketConfiguration(@NonNull final UUID userId) { 55 | final var userPlanMapping = userPlanMappingRepository.getActivePlan(userId); 56 | final var limitPerHour = userPlanMapping.getPlan().getLimitPerHour(); 57 | return BucketConfiguration.builder() 58 | .addLimit(limit -> limit.capacity(limitPerHour).refillIntervally(limitPerHour, Duration.ofHours(1))) 59 | .build(); 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/service/UserService.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import org.springframework.security.crypto.password.PasswordEncoder; 4 | import org.springframework.stereotype.Service; 5 | import org.springframework.transaction.annotation.Transactional; 6 | 7 | import com.behl.overseer.dto.TokenSuccessResponseDto; 8 | import com.behl.overseer.dto.UserCreationRequestDto; 9 | import com.behl.overseer.dto.UserLoginRequestDto; 10 | import com.behl.overseer.entity.User; 11 | import com.behl.overseer.entity.UserPlanMapping; 12 | import com.behl.overseer.exception.AccountAlreadyExistsException; 13 | import com.behl.overseer.exception.InvalidLoginCredentialsException; 14 | import com.behl.overseer.exception.InvalidPlanException; 15 | import com.behl.overseer.repository.PlanRepository; 16 | import com.behl.overseer.repository.UserPlanMappingRepository; 17 | import com.behl.overseer.repository.UserRepository; 18 | import com.behl.overseer.utility.JwtUtility; 19 | 20 | import lombok.NonNull; 21 | import lombok.RequiredArgsConstructor; 22 | 23 | @Service 24 | @RequiredArgsConstructor 25 | public class UserService { 26 | 27 | private final JwtUtility jwtUtility; 28 | private final UserRepository userRepository; 29 | private final PlanRepository planRepository; 30 | private final PasswordEncoder passwordEncoder; 31 | private final UserPlanMappingRepository userPlanMappingRepository; 32 | 33 | /** 34 | * Creates a new user account in the system corresponding to provided 35 | * subscription plan in the request. 36 | * 37 | * @param userCreationRequest containing user account details. 38 | * @throws IllegalArgumentException if provided argument is null. 39 | * @throws AccountAlreadyExistsException If an account with the provided email-id already exists. 40 | * @throws InvalidPlanException If the provided plan ID is invalid. 41 | */ 42 | @Transactional 43 | public void create(@NonNull final UserCreationRequestDto userCreationRequest) { 44 | final var emailId = userCreationRequest.getEmailId(); 45 | final var userAccountExistsWithEmailId = userRepository.existsByEmailId(emailId); 46 | if (Boolean.TRUE.equals(userAccountExistsWithEmailId)) { 47 | throw new AccountAlreadyExistsException("Account with provided email-id already exists"); 48 | } 49 | 50 | final var planId = userCreationRequest.getPlanId(); 51 | final var isPlanIdValid = planRepository.existsById(planId); 52 | if (Boolean.FALSE.equals(isPlanIdValid)) { 53 | throw new InvalidPlanException("No plan exists in the system with provided-id"); 54 | } 55 | 56 | final var user = new User(); 57 | final var encodedPassword = passwordEncoder.encode(userCreationRequest.getPassword()); 58 | user.setEmailId(emailId); 59 | user.setPassword(encodedPassword); 60 | final var savedUser = userRepository.save(user); 61 | 62 | final var userPlanMapping = new UserPlanMapping(); 63 | userPlanMapping.setUserId(savedUser.getId()); 64 | userPlanMapping.setPlanId(planId); 65 | userPlanMappingRepository.save(userPlanMapping); 66 | } 67 | 68 | /** 69 | * Validates user login credentials and generates an access token on successful 70 | * authentication. 71 | * 72 | * @param userLoginRequest The request object containing user login credentials. 73 | * @return The access token response containing the generated access token. 74 | * @throws IllegalArgumentException if provided argument is null. 75 | * @throws InvalidLoginCredentialsException If the provided login credentials are invalid. 76 | */ 77 | public TokenSuccessResponseDto login(@NonNull final UserLoginRequestDto userLoginRequest) { 78 | final var user = userRepository.findByEmailId(userLoginRequest.getEmailId()) 79 | .orElseThrow(InvalidLoginCredentialsException::new); 80 | 81 | final var encodedPassword = user.getPassword(); 82 | final var plainTextPassword = userLoginRequest.getPassword(); 83 | final var isCorrectPassword = passwordEncoder.matches(plainTextPassword, encodedPassword); 84 | if (Boolean.FALSE.equals(isCorrectPassword)) { 85 | throw new InvalidLoginCredentialsException(); 86 | } 87 | 88 | final var accessToken = jwtUtility.generateAccessToken(user.getId()); 89 | return TokenSuccessResponseDto.builder().accessToken(accessToken).build(); 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/utility/ApiEndpointSecurityInspector.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import static org.springframework.http.HttpMethod.GET; 4 | import static org.springframework.http.HttpMethod.POST; 5 | 6 | import java.util.ArrayList; 7 | import java.util.Collections; 8 | import java.util.List; 9 | import java.util.Optional; 10 | 11 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 12 | import org.springframework.stereotype.Component; 13 | import org.springframework.util.AntPathMatcher; 14 | import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; 15 | 16 | import com.behl.overseer.configuration.OpenApiConfigurationProperties; 17 | import com.behl.overseer.configuration.PublicEndpoint; 18 | 19 | import io.swagger.v3.oas.models.PathItem.HttpMethod; 20 | import jakarta.annotation.PostConstruct; 21 | import jakarta.servlet.http.HttpServletRequest; 22 | import lombok.Getter; 23 | import lombok.NonNull; 24 | import lombok.RequiredArgsConstructor; 25 | 26 | /** 27 | * Utility class responsible for evaluating the accessibility of API endpoints 28 | * based on their security configuration. It works in conjunction with the 29 | * mappings of controller methods annotated with {@link PublicEndpoint}. 30 | * 31 | * @see com.behl.overseer.configuration.PublicEndpoint 32 | * @see com.behl.overseer.configuration.OpenApiConfigurationProperties 33 | */ 34 | @Component 35 | @RequiredArgsConstructor 36 | @EnableConfigurationProperties(OpenApiConfigurationProperties.class) 37 | public class ApiEndpointSecurityInspector { 38 | 39 | private final RequestMappingHandlerMapping requestHandlerMapping; 40 | private final OpenApiConfigurationProperties openApiConfigurationProperties; 41 | private static final List SWAGGER_V3_PATHS = List.of("/swagger-ui**/**", "/v3/api-docs**/**"); 42 | 43 | @Getter 44 | private List publicGetEndpoints = new ArrayList(); 45 | @Getter 46 | private List publicPostEndpoints = new ArrayList(); 47 | 48 | /** 49 | * Initializes the class by gathering public endpoints for various HTTP methods. 50 | * It identifies designated public endpoints within the application's mappings 51 | * and adds them to separate lists based on their associated HTTP methods. 52 | * If OpenAPI is enabled, Swagger endpoints are also considered as public. 53 | */ 54 | @PostConstruct 55 | public void init() { 56 | final var handlerMethods = requestHandlerMapping.getHandlerMethods(); 57 | handlerMethods.forEach((requestInfo, handlerMethod) -> { 58 | if (handlerMethod.hasMethodAnnotation(PublicEndpoint.class)) { 59 | final var httpMethod = requestInfo.getMethodsCondition().getMethods().iterator().next().asHttpMethod(); 60 | final var apiPaths = requestInfo.getPathPatternsCondition().getPatternValues(); 61 | 62 | if (httpMethod.equals(GET)) { 63 | publicGetEndpoints.addAll(apiPaths); 64 | } else if (httpMethod.equals(POST)) { 65 | publicPostEndpoints.addAll(apiPaths); 66 | } 67 | 68 | } 69 | }); 70 | 71 | final var openApiEnabled = openApiConfigurationProperties.getOpenApi().isEnabled(); 72 | if (Boolean.TRUE.equals(openApiEnabled)) { 73 | publicGetEndpoints.addAll(SWAGGER_V3_PATHS); 74 | } 75 | } 76 | 77 | /** 78 | * Checks if the provided HTTP request is directed towards an unsecured API endpoint. 79 | * 80 | * @param request The HTTP request to inspect. 81 | * @return {@code true} if the request is to an unsecured API endpoint, {@code false} otherwise. 82 | */ 83 | public boolean isUnsecureRequest(@NonNull final HttpServletRequest request) { 84 | final var requestHttpMethod = HttpMethod.valueOf(request.getMethod()); 85 | var unsecuredApiPaths = getUnsecuredApiPaths(requestHttpMethod); 86 | unsecuredApiPaths = Optional.ofNullable(unsecuredApiPaths).orElseGet(ArrayList::new); 87 | 88 | return unsecuredApiPaths.stream().anyMatch(apiPath -> new AntPathMatcher().match(apiPath, request.getRequestURI())); 89 | } 90 | 91 | /** 92 | * Retrieves the list of unsecured API paths based on the provided HTTP method. 93 | * 94 | * @param httpMethod The HTTP method for which unsecured paths are to be retrieved. 95 | * @return A list of unsecured API paths for the specified HTTP method.s 96 | */ 97 | private List getUnsecuredApiPaths(@NonNull final HttpMethod httpMethod) { 98 | switch (httpMethod) { 99 | case GET: 100 | return publicGetEndpoints; 101 | case POST: 102 | return publicPostEndpoints; 103 | default: 104 | return Collections.emptyList(); 105 | } 106 | } 107 | 108 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/utility/AuthenticatedUserIdProvider.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import java.util.Optional; 4 | import java.util.UUID; 5 | 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.stereotype.Component; 9 | 10 | /** 11 | * Utility class dedicated to provide authenticated user's ID as stored in the 12 | * DataSource in UUID format which uniquely identifies the user in the system. 13 | * This is fetched from the principal in security context, where it is stored in 14 | * by the {@link com.behl.overseer.filter.JwtAuthenticationFilter} during HTTP 15 | * request evaluation through the filter chain. 16 | * 17 | * @see com.behl.overseer.filter.JwtAuthenticationFilter 18 | */ 19 | @Component 20 | public class AuthenticatedUserIdProvider { 21 | 22 | /** 23 | * Retrieves ID corresponding to the authenticated user from the security 24 | * context. 25 | * 26 | * @return Unique ID (UUID formatted) corresponding to the authenticated user. 27 | * @throws IllegalStateException if the method is invoked when a request was 28 | * destined to a public API endpoint and did not pass 29 | * the JwtAuthenticationFilter 30 | */ 31 | public UUID getUserId() { 32 | return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) 33 | .map(Authentication::getPrincipal) 34 | .filter(UUID.class::isInstance) 35 | .map(UUID.class::cast) 36 | .orElseThrow(IllegalStateException::new); 37 | } 38 | 39 | /** 40 | * Checks whether the security context is populated with a valid authentication 41 | * object. 42 | * 43 | * @return {@code true} if an authentication context is available, {@code false} 44 | * otherwise. 45 | */ 46 | public boolean isAvailable() { 47 | final var authentication = SecurityContextHolder.getContext().getAuthentication(); 48 | return Optional.ofNullable(authentication).isPresent(); 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/utility/JokeGenerator.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import com.behl.overseer.dto.JokeResponseDto; 6 | 7 | import net.datafaker.Faker; 8 | import net.datafaker.providers.entertainment.Joke; 9 | 10 | /** 11 | * Utility class for generating random jokes. 12 | */ 13 | @Component 14 | public class JokeGenerator { 15 | 16 | private final Joke joke; 17 | 18 | public JokeGenerator() { 19 | this.joke = new Faker().joke(); 20 | } 21 | 22 | /** 23 | * Generates a random joke. 24 | * 25 | * @return JokeResponseDto containing the generated joke 26 | */ 27 | public JokeResponseDto generate() { 28 | final var pun = joke.pun(); 29 | return JokeResponseDto.builder().joke(pun).build(); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/behl/overseer/utility/JwtUtility.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import java.util.Date; 4 | import java.util.UUID; 5 | import java.util.concurrent.TimeUnit; 6 | import java.util.function.Function; 7 | 8 | import javax.crypto.SecretKey; 9 | 10 | import org.apache.commons.lang3.StringUtils; 11 | import org.springframework.beans.factory.annotation.Value; 12 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 13 | import org.springframework.stereotype.Component; 14 | 15 | import com.behl.overseer.configuration.TokenConfigurationProperties; 16 | 17 | import io.jsonwebtoken.Claims; 18 | import io.jsonwebtoken.Jwts; 19 | import io.jsonwebtoken.io.Decoders; 20 | import io.jsonwebtoken.security.Keys; 21 | import lombok.NonNull; 22 | 23 | /** 24 | * Utility class for JWT (JSON Web Token) operations, responsible for handling 25 | * JWT generation, signature verification, and extracting required claims from 26 | * JWT tokens. It interacts with the application's token configuration 27 | * properties to ensure correct token creation and validation. 28 | * 29 | * @see com.behl.overseer.configuration.TokenConfigurationProperties 30 | * @see com.behl.overseer.filter.JwtAuthenticationFilter 31 | */ 32 | @Component 33 | @EnableConfigurationProperties(TokenConfigurationProperties.class) 34 | public class JwtUtility { 35 | 36 | private static final String BEARER_PREFIX = "Bearer "; 37 | 38 | private final String issuer; 39 | private final TokenConfigurationProperties tokenConfigurationProperties; 40 | 41 | public JwtUtility(@Value("${spring.application.name}") final String issuer, 42 | final TokenConfigurationProperties tokenConfigurationProperties) { 43 | this.issuer = issuer; 44 | this.tokenConfigurationProperties = tokenConfigurationProperties; 45 | } 46 | 47 | /** 48 | * Generates an access token corresponding to provided user-id based on 49 | * configured settings. The generated access token can be used to perform tasks 50 | * on behalf of the user on subsequent HTTP calls to the application until it 51 | * expires. 52 | * 53 | * @param userId The userId against which an access token is to be generated. 54 | * @throws IllegalArgumentException if provided argument is null. 55 | * @return The generated JWT access token. 56 | */ 57 | public String generateAccessToken(@NonNull final UUID userId) { 58 | final var audience = String.valueOf(userId); 59 | 60 | final var accessTokenValidity = tokenConfigurationProperties.getValidity(); 61 | final var expiration = TimeUnit.MINUTES.toMillis(accessTokenValidity); 62 | final var currentTimestamp = new Date(System.currentTimeMillis()); 63 | final var expirationTimestamp = new Date(System.currentTimeMillis() + expiration); 64 | 65 | final var encodedSecretKey = tokenConfigurationProperties.getSecretKey(); 66 | final var secretKey = getSecretKey(encodedSecretKey); 67 | 68 | return Jwts.builder() 69 | .issuer(issuer) 70 | .issuedAt(currentTimestamp) 71 | .expiration(expirationTimestamp) 72 | .audience().add(audience) 73 | .and() 74 | .signWith(secretKey, Jwts.SIG.HS256) 75 | .compact(); 76 | } 77 | 78 | /** 79 | * Extracts user's ID from a given JWT token signifying an authenticated 80 | * user. 81 | * 82 | * @param token The JWT token from which to extract the user's ID. 83 | * @throws IllegalArgumentException if provided argument is null. 84 | * @return The authenticated user's unique identifier (ID) in UUID format. 85 | */ 86 | public UUID getUserId(@NonNull final String token) { 87 | final var audience = extractClaim(token, Claims::getAudience).iterator().next(); 88 | return UUID.fromString(audience); 89 | } 90 | 91 | /** 92 | * Extracts a specific claim from the provided JWT token. This method verifies 93 | * the token's issuer and signature before extracting the claim. 94 | * 95 | * @param token JWT token from which the desired claim is to be extracted. 96 | * @param claimsResolver function of {@link Claims} to execute. example: {@code Claims::getId}. 97 | * @throws IllegalArgumentException if any provided argument is null 98 | * @return The extracted claim value from the JWT token. 99 | */ 100 | private T extractClaim(@NonNull final String token, @NonNull final Function claimsResolver) { 101 | final var encodedSecretKey = tokenConfigurationProperties.getSecretKey(); 102 | final var secretKey = getSecretKey(encodedSecretKey); 103 | final var sanitizedToken = token.replace(BEARER_PREFIX, StringUtils.EMPTY); 104 | final var claims = Jwts.parser() 105 | .requireIssuer(issuer) 106 | .verifyWith(secretKey) 107 | .build() 108 | .parseSignedClaims(sanitizedToken) 109 | .getPayload(); 110 | return claimsResolver.apply(claims); 111 | } 112 | 113 | /** 114 | * Constructs an instance of {@link SecretKey} from the provided Base64-encoded 115 | * secret key string. 116 | * 117 | * @param encodedKey The Base64-encoded secret key string. 118 | * @throws IllegalArgumentException if encodedKey is null 119 | * @return A {@link SecretKey} instance for JWT signing and verification. 120 | */ 121 | private SecretKey getSecretKey(@NonNull final String encodedKey) { 122 | final var decodedKey = Decoders.BASE64.decode(encodedKey); 123 | return Keys.hmacShaKeyFor(decodedKey); 124 | } 125 | 126 | } -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | spring: 4 | application: 5 | name: overseer 6 | datasource: 7 | url: ${MYSQL_URL} 8 | username: ${MYSQL_USERNAME} 9 | password: ${MYSQL_PASSWORD} 10 | driver-class-name: com.mysql.cj.jdbc.Driver 11 | data: 12 | redis: 13 | host: ${REDIS_HOSTNAME} 14 | port: ${REDIS_PORT} 15 | password: ${REDIS_PASSWORD} 16 | jackson: 17 | deserialization: 18 | fail-on-unknown-properties: true 19 | 20 | com: 21 | behl: 22 | overseer: 23 | token: 24 | secret-key: ${JWT_SECRET_KEY} 25 | validity: 120 26 | open-api: 27 | enabled: true 28 | api-version: 1.0.0 29 | title: Overseer 30 | description: Backend application imposing rate limit on private API endpoints using token bucket algorithm -------------------------------------------------------------------------------- /src/main/resources/db/migration/V001__creating_database_tables.sql: -------------------------------------------------------------------------------- 1 | -- Create users table 2 | CREATE TABLE users ( 3 | id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), 4 | email_id VARCHAR(50) NOT NULL UNIQUE, 5 | password VARCHAR(72) NOT NULL, 6 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | -- Create plans table 10 | CREATE TABLE plans ( 11 | id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), 12 | name VARCHAR(20) NOT NULL UNIQUE, 13 | limit_per_hour INT NOT NULL UNIQUE, 14 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 15 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 16 | ); 17 | 18 | -- Create user_plan_mappings table 19 | CREATE TABLE user_plan_mappings ( 20 | id BINARY(16) PRIMARY KEY DEFAULT (UUID_TO_BIN(UUID())), 21 | user_id BINARY(16) NOT NULL, 22 | plan_id BINARY(16) NOT NULL, 23 | is_active TINYINT(1) NOT NULL, 24 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 25 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 26 | FOREIGN KEY (user_id) REFERENCES users (id), 27 | FOREIGN KEY (plan_id) REFERENCES plans (id) 28 | ); -------------------------------------------------------------------------------- /src/main/resources/db/migration/V002__adding_plans.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO plans (name, limit_per_hour) 2 | VALUES ('FREE', 20); 3 | 4 | INSERT INTO plans (name, limit_per_hour) 5 | VALUES ('BUSINESS', 40); 6 | 7 | INSERT INTO plans (name, limit_per_hour) 8 | VALUES ('PROFESSIONAL', 100); -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/InitializeApplicationSecretKey.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | 10 | @Target(ElementType.TYPE) 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @ExtendWith(SecretKeyInitializer.class) 13 | public @interface InitializeApplicationSecretKey { 14 | 15 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/InitializeMysqlContainer.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | 10 | @Target(ElementType.TYPE) 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @ExtendWith(MySQLDataSourceInitializer.class) 13 | public @interface InitializeMysqlContainer { 14 | 15 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/InitializeRedisContainer.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | 10 | @Target(ElementType.TYPE) 11 | @Retention(RetentionPolicy.RUNTIME) 12 | @ExtendWith(RedisCacheInitializer.class) 13 | public @interface InitializeRedisContainer { 14 | 15 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/MySQLDataSourceInitializer.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import org.junit.jupiter.api.extension.BeforeAllCallback; 4 | import org.junit.jupiter.api.extension.ExtensionContext; 5 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; 6 | import org.testcontainers.containers.MySQLContainer; 7 | import org.testcontainers.utility.DockerImageName; 8 | 9 | import lombok.extern.slf4j.Slf4j; 10 | 11 | @Slf4j 12 | public class MySQLDataSourceInitializer implements BeforeAllCallback { 13 | 14 | private static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8"); 15 | private static final MySQLContainer mySQLContainer = new MySQLContainer<>(MYSQL_IMAGE); 16 | 17 | @Override 18 | public void beforeAll(final ExtensionContext context) { 19 | log.info("Creating test datasource container : {}", MYSQL_IMAGE); 20 | mySQLContainer.start(); 21 | addDataSourceProperties(); 22 | log.info("Successfully started test datasource container : {}", MYSQL_IMAGE); 23 | } 24 | 25 | private void addDataSourceProperties() { 26 | System.setProperty("spring.datasource.url", mySQLContainer.getJdbcUrl()); 27 | System.setProperty("spring.datasource.username", mySQLContainer.getUsername()); 28 | System.setProperty("spring.datasource.password", mySQLContainer.getPassword()); 29 | System.setProperty("spring.test.database.replace", Replace.NONE.name()); 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/RedisCacheInitializer.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import org.junit.jupiter.api.extension.BeforeAllCallback; 4 | import org.junit.jupiter.api.extension.ExtensionContext; 5 | import org.testcontainers.containers.GenericContainer; 6 | import org.testcontainers.utility.DockerImageName; 7 | 8 | import lombok.extern.slf4j.Slf4j; 9 | import net.bytebuddy.utility.RandomString; 10 | 11 | @Slf4j 12 | public class RedisCacheInitializer implements BeforeAllCallback { 13 | 14 | private static final int REDIS_PORT = 6379; 15 | private static final String REDIS_PASSWORD = RandomString.make(10); 16 | private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.2.3-alpine"); 17 | 18 | private static final GenericContainer redisContainer = new GenericContainer<>(REDIS_IMAGE) 19 | .withExposedPorts(REDIS_PORT).withCommand("redis-server", "--requirepass", REDIS_PASSWORD); 20 | 21 | @Override 22 | public void beforeAll(final ExtensionContext context) { 23 | log.info("Creating test cache container : {}", REDIS_IMAGE); 24 | redisContainer.start(); 25 | addCacheProperties(); 26 | log.info("Successfully started test cache container : {}", REDIS_IMAGE); 27 | } 28 | 29 | private void addCacheProperties() { 30 | System.setProperty("spring.data.redis.host", redisContainer.getHost()); 31 | System.setProperty("spring.data.redis.port", String.valueOf(redisContainer.getMappedPort(REDIS_PORT))); 32 | System.setProperty("spring.data.redis.password", REDIS_PASSWORD); 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/SecretKeyInitializer.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer; 2 | 3 | import org.junit.jupiter.api.extension.BeforeAllCallback; 4 | import org.junit.jupiter.api.extension.ExtensionContext; 5 | 6 | import io.jsonwebtoken.Jwts; 7 | import io.jsonwebtoken.io.Encoders; 8 | import lombok.extern.slf4j.Slf4j; 9 | 10 | @Slf4j 11 | public class SecretKeyInitializer implements BeforeAllCallback { 12 | 13 | @Override 14 | public void beforeAll(final ExtensionContext context) { 15 | log.info("Initializing secret key in application"); 16 | final var secretKey = Encoders.BASE64.encode(Jwts.SIG.HS256.key().build().getEncoded()); 17 | System.setProperty("com.behl.overseer.token.secret-key", secretKey); 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/controller/AuthenticationControllerIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.controller; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 6 | 7 | import java.util.UUID; 8 | 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | 17 | import com.behl.overseer.InitializeApplicationSecretKey; 18 | import com.behl.overseer.InitializeMysqlContainer; 19 | import com.behl.overseer.InitializeRedisContainer; 20 | import com.behl.overseer.service.PlanService; 21 | 22 | import lombok.SneakyThrows; 23 | import net.bytebuddy.utility.RandomString; 24 | 25 | @SpringBootTest 26 | @AutoConfigureMockMvc 27 | @InitializeRedisContainer 28 | @InitializeMysqlContainer 29 | @InitializeApplicationSecretKey 30 | class AuthenticationControllerIT { 31 | 32 | @Autowired 33 | private MockMvc mockMvc; 34 | 35 | @Autowired 36 | private PlanService planService; 37 | 38 | @Test 39 | @SneakyThrows 40 | void userCreationShouldFailForInvalidPlanId() { 41 | // construct invalid random plan-id 42 | final var planId = UUID.randomUUID(); 43 | 44 | // prepare API request body to create user 45 | final var emailId = RandomString.make() + "@domain.it"; 46 | final var requestBody = String.format(""" 47 | { 48 | "EmailId" : "%s", 49 | "Password" : "SomethingSecure", 50 | "PlanId" : "%s" 51 | } 52 | """, emailId, planId); 53 | 54 | // execute API request 55 | final var apiPath = "/api/v1/user"; 56 | mockMvc.perform(post(apiPath) 57 | .contentType(MediaType.APPLICATION_JSON) 58 | .content(requestBody)) 59 | .andExpect(status().isNotFound()) 60 | .andExpect(jsonPath("$.Status").value(HttpStatus.NOT_FOUND.toString())) 61 | .andExpect(jsonPath("$.Description").value("No plan exists in the system with provided-id")); 62 | } 63 | 64 | @Test 65 | @SneakyThrows 66 | void shouldCreateUserEntityForValidUserCreationRequest() { 67 | // fetch plan from datasource 68 | final var plan = planService.retrieve().get(0); 69 | 70 | // prepare API request body to create user 71 | final var emailId = RandomString.make() + "@domain.it"; 72 | final var requestBody = String.format(""" 73 | { 74 | "EmailId" : "%s", 75 | "Password" : "SomethingSecure", 76 | "PlanId" : "%s" 77 | } 78 | """, emailId, plan.getId()); 79 | 80 | // execute API request 81 | final var apiPath = "/api/v1/user"; 82 | mockMvc.perform(post(apiPath) 83 | .contentType(MediaType.APPLICATION_JSON) 84 | .content(requestBody)) 85 | .andExpect(status().isCreated()); 86 | } 87 | 88 | @Test 89 | @SneakyThrows 90 | void userCreationShouldThrowExceptionForDuplicateEmailId() { 91 | // fetch plan from datasource 92 | final var plan = planService.retrieve().get(0); 93 | 94 | // prepare API request body to create user 95 | final var emailId = RandomString.make() + "@domain.it"; 96 | final var requestBody = String.format(""" 97 | { 98 | "EmailId" : "%s", 99 | "Password" : "SomethingSecure", 100 | "PlanId" : "%s" 101 | } 102 | """, emailId, plan.getId()); 103 | 104 | // execute API request and assert success in initial execution 105 | final var apiPath = "/api/v1/user"; 106 | mockMvc.perform(post(apiPath) 107 | .contentType(MediaType.APPLICATION_JSON) 108 | .content(requestBody)) 109 | .andExpect(status().isCreated()); 110 | 111 | // execute API request with same email-id again 112 | mockMvc.perform(post(apiPath) 113 | .contentType(MediaType.APPLICATION_JSON) 114 | .content(requestBody)) 115 | .andExpect(status().isConflict()) 116 | .andExpect(jsonPath("$.Status").value(HttpStatus.CONFLICT.toString())) 117 | .andExpect(jsonPath("$.Description").value("Account with provided email-id already exists")); 118 | } 119 | 120 | @Test 121 | @SneakyThrows 122 | void loginShouldReturnUnauthorizedAgainstUnregisteredEmailId() { 123 | // prepare API request body for login with random email-id 124 | final var emailId = RandomString.make() + "@domain.it"; 125 | final var requestBody = String.format(""" 126 | { 127 | "EmailId" : "%s", 128 | "Password" : "SomethingSecure" 129 | } 130 | """, emailId); 131 | 132 | // execute API request 133 | final var apiPath = "/api/v1/auth/login"; 134 | mockMvc.perform(post(apiPath) 135 | .contentType(MediaType.APPLICATION_JSON) 136 | .content(requestBody)) 137 | .andExpect(status().isUnauthorized()) 138 | .andExpect(jsonPath("$.Status").value(HttpStatus.UNAUTHORIZED.toString())) 139 | .andExpect(jsonPath("$.Description").value("Invalid login credentials provided")); 140 | } 141 | 142 | @Test 143 | @SneakyThrows 144 | void loginShouldReturnUnauthorizedAgainstInvalidPassword() { 145 | // fetch plan from datasource 146 | final var plan = planService.retrieve().get(0); 147 | 148 | // prepare API request body to create user 149 | final var emailId = RandomString.make() + "@domain.it"; 150 | final var userCreationRequestBody = String.format(""" 151 | { 152 | "EmailId" : "%s", 153 | "Password" : "SomethingSecure", 154 | "PlanId" : "%s" 155 | } 156 | """, emailId, plan.getId()); 157 | 158 | // execute API request to create user 159 | final var userCreationApiPath = "/api/v1/user"; 160 | mockMvc.perform(post(userCreationApiPath) 161 | .contentType(MediaType.APPLICATION_JSON) 162 | .content(userCreationRequestBody)) 163 | .andExpect(status().isCreated()); 164 | 165 | // prepare API request body with invalid password 166 | final var password = RandomString.make(); 167 | final var loginRequestBody = String.format(""" 168 | { 169 | "EmailId" : "%s", 170 | "Password" : "%s" 171 | } 172 | """, emailId, password); 173 | 174 | // execute API request for login 175 | final var loginApiPath = "/api/v1/auth/login"; 176 | mockMvc.perform(post(loginApiPath) 177 | .contentType(MediaType.APPLICATION_JSON) 178 | .content(loginRequestBody)) 179 | .andExpect(status().isUnauthorized()) 180 | .andExpect(jsonPath("$.Status").value(HttpStatus.UNAUTHORIZED.toString())) 181 | .andExpect(jsonPath("$.Description").value("Invalid login credentials provided")); 182 | } 183 | 184 | @Test 185 | @SneakyThrows 186 | void shouldReturnAccessTokenForValidLoginRequest() { 187 | // fetch plan from datasource 188 | final var plan = planService.retrieve().get(0); 189 | 190 | // prepare API request body to create user 191 | final var emailId = RandomString.make() + "@domain.it"; 192 | final var password = RandomString.make(); 193 | final var userCreationRequestBody = String.format(""" 194 | { 195 | "EmailId" : "%s", 196 | "Password" : "%s", 197 | "PlanId" : "%s" 198 | } 199 | """, emailId, password, plan.getId()); 200 | 201 | // execute API request to create user 202 | final var userCreationApiPath = "/api/v1/user"; 203 | mockMvc.perform(post(userCreationApiPath) 204 | .contentType(MediaType.APPLICATION_JSON) 205 | .content(userCreationRequestBody)) 206 | .andExpect(status().isCreated()); 207 | 208 | // prepare API request body for login with valid credentials 209 | final var loginRequestBody = String.format(""" 210 | { 211 | "EmailId" : "%s", 212 | "Password" : "%s" 213 | } 214 | """, emailId, password); 215 | 216 | // execute API request for login 217 | final var loginApiPath = "/api/v1/auth/login"; 218 | mockMvc.perform(post(loginApiPath) 219 | .contentType(MediaType.APPLICATION_JSON) 220 | .content(loginRequestBody)) 221 | .andExpect(status().isOk()) 222 | .andExpect(jsonPath("$.AccessToken").exists()); 223 | } 224 | 225 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/controller/JokeControllerIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.controller; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 5 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 7 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 8 | 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.configurationprocessor.json.JSONObject; 12 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.http.HttpStatus; 15 | import org.springframework.http.MediaType; 16 | import org.springframework.test.web.servlet.MockMvc; 17 | 18 | import com.behl.overseer.InitializeApplicationSecretKey; 19 | import com.behl.overseer.InitializeMysqlContainer; 20 | import com.behl.overseer.InitializeRedisContainer; 21 | import com.behl.overseer.service.PlanService; 22 | 23 | import lombok.SneakyThrows; 24 | import net.bytebuddy.utility.RandomString; 25 | 26 | @SpringBootTest 27 | @AutoConfigureMockMvc 28 | @InitializeRedisContainer 29 | @InitializeMysqlContainer 30 | @InitializeApplicationSecretKey 31 | class JokeControllerIT { 32 | 33 | @Autowired 34 | private MockMvc mockMvc; 35 | 36 | @Autowired 37 | private PlanService planService; 38 | 39 | @Test 40 | @SneakyThrows 41 | void generateJokeShouldThrowUnauthorizedForMissingAccessToken() { 42 | // invoke private joke endpoint without access token 43 | final var apiPath = "/api/v1/joke"; 44 | mockMvc.perform(get(apiPath)) 45 | .andExpect(status().isUnauthorized()); 46 | } 47 | 48 | @Test 49 | @SneakyThrows 50 | void shouldGenerateJokeForValidAccessToken() { 51 | // get valid user access token 52 | final var accessToken = createUserAndGenerateAccessToken(); 53 | 54 | // invoke private joke endpoint with token 55 | final var apiPath = "/api/v1/joke"; 56 | mockMvc.perform(get(apiPath) 57 | .header("Authorization", "Bearer " + accessToken)) 58 | .andExpect(status().isOk()) 59 | .andExpect(jsonPath("$.Joke").isNotEmpty()) 60 | .andExpect(header().exists("X-Rate-Limit-Remaining")); 61 | } 62 | 63 | @Test 64 | @SneakyThrows 65 | void shouldThrowRateLimitErrorIfJokeEndpointIsSpammed() { 66 | // get valid user access token 67 | final var accessToken = createUserAndGenerateAccessToken(); 68 | 69 | // invoke private joke endpoint intially with token 70 | final var apiPath = "/api/v1/joke"; 71 | final var response = mockMvc.perform(get(apiPath) 72 | .header("Authorization", "Bearer " + accessToken)) 73 | .andExpect(status().isOk()) 74 | .andExpect(jsonPath("$.Joke").isNotEmpty()) 75 | .andExpect(header().exists("X-Rate-Limit-Remaining")) 76 | .andReturn(); 77 | 78 | // retrieve rate limit remaining from response header 79 | final var headerResponse = response.getResponse().getHeader("X-Rate-Limit-Remaining"); 80 | final var rateLimitRemaining = Integer.parseInt(headerResponse); 81 | 82 | // exhaust available rate limit 83 | for (int i = 0; i < rateLimitRemaining; i++) { 84 | mockMvc.perform(get(apiPath) 85 | .header("Authorization", "Bearer " + accessToken)) 86 | .andExpect(status().isOk()) 87 | .andExpect(jsonPath("$.Joke").isNotEmpty()) 88 | .andExpect(header().exists("X-Rate-Limit-Remaining")); 89 | } 90 | 91 | // invoke endpoint after rate limit exhaustion 92 | mockMvc.perform(get(apiPath) 93 | .header("Authorization", "Bearer " + accessToken)) 94 | .andExpect(status().isTooManyRequests()) 95 | .andExpect(jsonPath("$.Status").value(HttpStatus.TOO_MANY_REQUESTS.toString())) 96 | .andExpect(jsonPath("$.Description").value("API request limit linked to your current plan has been exhausted.")) 97 | .andExpect(header().exists("X-Rate-Limit-Retry-After-Seconds")); 98 | } 99 | 100 | @SneakyThrows 101 | private String createUserAndGenerateAccessToken() { 102 | // fetch plan from datasource 103 | final var plan = planService.retrieve().get(0); 104 | 105 | // prepare API request body to create user 106 | final var emailId = RandomString.make() + "@domain.it"; 107 | final var password = RandomString.make(); 108 | final var userCreationRequestBody = String.format(""" 109 | { 110 | "EmailId" : "%s", 111 | "Password" : "%s", 112 | "PlanId" : "%s" 113 | } 114 | """, emailId, password, plan.getId()); 115 | 116 | // execute API request to create user 117 | final var userCreationApiPath = "/api/v1/user"; 118 | mockMvc.perform(post(userCreationApiPath) 119 | .contentType(MediaType.APPLICATION_JSON) 120 | .content(userCreationRequestBody)) 121 | .andExpect(status().isCreated()); 122 | 123 | // prepare API request body for login with valid credentials 124 | final var loginRequestBody = String.format(""" 125 | { 126 | "EmailId" : "%s", 127 | "Password" : "%s" 128 | } 129 | """, emailId, password); 130 | 131 | // execute API request for login 132 | final var loginApiPath = "/api/v1/auth/login"; 133 | final var response = mockMvc.perform(post(loginApiPath) 134 | .contentType(MediaType.APPLICATION_JSON) 135 | .content(loginRequestBody)) 136 | .andExpect(status().isOk()) 137 | .andExpect(jsonPath("$.AccessToken").exists()) 138 | .andReturn(); 139 | 140 | final var jsonResponse = response.getResponse().getContentAsString(); 141 | final var jsonObject = new JSONObject(jsonResponse); 142 | return jsonObject.getString("AccessToken"); 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/controller/PlanControllerIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.controller; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 5 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 6 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 7 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 9 | 10 | import java.util.UUID; 11 | 12 | import org.junit.jupiter.api.Test; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.configurationprocessor.json.JSONObject; 15 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 16 | import org.springframework.boot.test.context.SpringBootTest; 17 | import org.springframework.http.MediaType; 18 | import org.springframework.test.web.servlet.MockMvc; 19 | 20 | import com.behl.overseer.InitializeApplicationSecretKey; 21 | import com.behl.overseer.InitializeMysqlContainer; 22 | import com.behl.overseer.InitializeRedisContainer; 23 | import com.behl.overseer.service.PlanService; 24 | 25 | import lombok.SneakyThrows; 26 | import net.bytebuddy.utility.RandomString; 27 | 28 | @SpringBootTest 29 | @AutoConfigureMockMvc 30 | @InitializeRedisContainer 31 | @InitializeMysqlContainer 32 | @InitializeApplicationSecretKey 33 | class PlanControllerIT { 34 | 35 | @Autowired 36 | private MockMvc mockMvc; 37 | 38 | @Autowired 39 | private PlanService planService; 40 | 41 | @Test 42 | @SneakyThrows 43 | void shouldFetchAvailablePlansFromSystem() { 44 | // execute API request 45 | final var apiPath = "/api/v1/plan"; 46 | mockMvc.perform(get(apiPath)) 47 | .andExpect(status().isOk()) 48 | .andDo(print()) 49 | .andExpect(jsonPath("$.[*].Id").exists()) 50 | .andExpect(jsonPath("$.[*].Name").exists()) 51 | .andExpect(jsonPath("$.[*].LimitPerHour").exists()); 52 | } 53 | 54 | @Test 55 | @SneakyThrows 56 | void shouldUpdateUserPlanSuccessfully() { 57 | // fetch available plans from system 58 | final var plans = planService.retrieve(); 59 | final var userPlan = plans.get(0); 60 | final var planToUpdate = plans.get(1); 61 | 62 | // create user and get valid user access token 63 | final var accessToken = createUserAndGenerateAccessToken(userPlan.getId()); 64 | 65 | // prepare API request body to update user plan 66 | final var requestBody = String.format(""" 67 | { 68 | "PlanId" : "%s" 69 | } 70 | """, planToUpdate.getId()); 71 | 72 | // execute API request 73 | final var apiPath = "/api/v1/plan"; 74 | mockMvc.perform(put(apiPath) 75 | .contentType(MediaType.APPLICATION_JSON) 76 | .content(requestBody) 77 | .header("Authorization", "Bearer " + accessToken)) 78 | .andExpect(status().isOk()); 79 | } 80 | 81 | @SneakyThrows 82 | private String createUserAndGenerateAccessToken(UUID planId) { 83 | // prepare API request body to create user 84 | final var emailId = RandomString.make() + "@domain.it"; 85 | final var password = RandomString.make(); 86 | final var userCreationRequestBody = String.format(""" 87 | { 88 | "EmailId" : "%s", 89 | "Password" : "%s", 90 | "PlanId" : "%s" 91 | } 92 | """, emailId, password, planId); 93 | 94 | // execute API request to create user 95 | final var userCreationApiPath = "/api/v1/user"; 96 | mockMvc.perform(post(userCreationApiPath) 97 | .contentType(MediaType.APPLICATION_JSON) 98 | .content(userCreationRequestBody)) 99 | .andExpect(status().isCreated()); 100 | 101 | // prepare API request body for login with valid credentials 102 | final var loginRequestBody = String.format(""" 103 | { 104 | "EmailId" : "%s", 105 | "Password" : "%s" 106 | } 107 | """, emailId, password); 108 | 109 | // execute API request for login 110 | final var loginApiPath = "/api/v1/auth/login"; 111 | final var response = mockMvc.perform(post(loginApiPath) 112 | .contentType(MediaType.APPLICATION_JSON) 113 | .content(loginRequestBody)) 114 | .andExpect(status().isOk()) 115 | .andExpect(jsonPath("$.AccessToken").exists()) 116 | .andReturn(); 117 | 118 | final var jsonResponse = response.getResponse().getContentAsString(); 119 | final var jsonObject = new JSONObject(jsonResponse); 120 | return jsonObject.getString("AccessToken"); 121 | } 122 | 123 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/repository/PlanRepositoryIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.repository; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 8 | 9 | import com.behl.overseer.InitializeMysqlContainer; 10 | 11 | @DataJpaTest 12 | @InitializeMysqlContainer 13 | class PlanRepositoryIT { 14 | 15 | @Autowired 16 | private PlanRepository planRepository; 17 | 18 | /** 19 | * @see src/main/resources/db/migration/V002__adding_plans.sql 20 | */ 21 | @Test 22 | void evaluateRunOfFlywayMigrationScript() { 23 | // fetch all plan records from datasource 24 | final var plans = planRepository.findAll(); 25 | 26 | // assert fetched record's attributes 27 | assertThat(plans).hasSize(3); 28 | plans.forEach(plan -> { 29 | assertThat(plan.getId()).isNotNull(); 30 | assertThat(plan.getName()).isNotNull(); 31 | assertThat(plan.getLimitPerHour()).isNotNull(); 32 | assertThat(plan.getCreatedAt()).isNotNull(); 33 | assertThat(plan.getUpdatedAt()).isNotNull(); 34 | }); 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/repository/UserPlanMappingRepositoryIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.repository; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 8 | 9 | import com.behl.overseer.InitializeMysqlContainer; 10 | import com.behl.overseer.entity.User; 11 | import com.behl.overseer.entity.UserPlanMapping; 12 | 13 | import net.bytebuddy.utility.RandomString; 14 | 15 | @DataJpaTest 16 | @InitializeMysqlContainer 17 | class UserPlanMappingRepositoryIT { 18 | 19 | @Autowired 20 | private UserRepository userRepository; 21 | 22 | @Autowired 23 | private PlanRepository planRepository; 24 | 25 | @Autowired 26 | private UserPlanMappingRepository userPlanMappingRepository; 27 | 28 | @Test 29 | void shouldDeactivateCurrentPlanOfUser() { 30 | // insert test user record in datasource 31 | final var emailId = RandomString.make(); 32 | final var password = RandomString.make(); 33 | final var user = new User(); 34 | user.setEmailId(emailId); 35 | user.setPassword(password); 36 | final var savedUser = userRepository.save(user); 37 | 38 | // fetch a plan record from datasource 39 | final var plan = planRepository.findAll().get(0); 40 | 41 | // insert an active user plan mapping record 42 | final var userPlanMapping = new UserPlanMapping(); 43 | userPlanMapping.setUserId(savedUser.getId()); 44 | userPlanMapping.setPlanId(plan.getId()); 45 | final var savedUserPlanMapping = userPlanMappingRepository.save(userPlanMapping); 46 | 47 | // assert saved user plan mapping is active 48 | assertThat(savedUserPlanMapping.getIsActive()).isTrue(); 49 | 50 | // invoke method under test 51 | userPlanMappingRepository.deactivateCurrentPlan(savedUser.getId()); 52 | 53 | // fetch updated record and assert active status 54 | final var fetchedUserPlanMapping = userPlanMappingRepository.findById(savedUserPlanMapping.getId()); 55 | assertThat(fetchedUserPlanMapping).isPresent().get().satisfies(userPlan -> { 56 | assertThat(userPlan.getIsActive()).isFalse(); 57 | }); 58 | } 59 | 60 | @Test 61 | void shouldGetActivePlanForUser() { 62 | // insert test user record in datasource 63 | final var emailId = RandomString.make(); 64 | final var password = RandomString.make(); 65 | final var user = new User(); 66 | user.setEmailId(emailId); 67 | user.setPassword(password); 68 | final var savedUser = userRepository.save(user); 69 | 70 | // fetch a plan record from datasource 71 | final var plan = planRepository.findAll().get(0); 72 | 73 | // insert an active user plan mapping record 74 | final var userPlanMapping = new UserPlanMapping(); 75 | userPlanMapping.setUserId(savedUser.getId()); 76 | userPlanMapping.setPlanId(plan.getId()); 77 | final var savedUserPlanMapping = userPlanMappingRepository.save(userPlanMapping); 78 | 79 | // invoke method under test 80 | final var fetchedUserPlanMapping = userPlanMappingRepository.getActivePlan(savedUser.getId()); 81 | 82 | // assert the fetched record is user's active plan mapping 83 | assertThat(fetchedUserPlanMapping.getIsActive()).isTrue(); 84 | assertThat(fetchedUserPlanMapping.getId()).isEqualTo(savedUserPlanMapping.getId()); 85 | assertThat(fetchedUserPlanMapping.getUserId()).isEqualTo(savedUser.getId()); 86 | assertThat(fetchedUserPlanMapping.getPlanId()).isEqualTo(plan.getId()); 87 | } 88 | 89 | @Test 90 | void shouldEvaluateActiveUserPlanByPlanId() { 91 | // insert test user record in datasource 92 | final var emailId = RandomString.make(); 93 | final var password = RandomString.make(); 94 | final var user = new User(); 95 | user.setEmailId(emailId); 96 | user.setPassword(password); 97 | final var savedUser = userRepository.save(user); 98 | 99 | // fetch plan records from datasource 100 | final var plans = planRepository.findAll(); 101 | final var userPlan = plans.get(0); 102 | final var nonUserPlan = plans.get(1); 103 | 104 | // insert an active user plan mapping record 105 | final var userPlanMapping = new UserPlanMapping(); 106 | userPlanMapping.setUserId(savedUser.getId()); 107 | userPlanMapping.setPlanId(userPlan.getId()); 108 | userPlanMappingRepository.save(userPlanMapping); 109 | 110 | // invoke method under test for valid plan-id and assert response 111 | Boolean response; 112 | response = userPlanMappingRepository.isActivePlan(savedUser.getId(), userPlan.getId()); 113 | assertThat(response).isTrue(); 114 | 115 | // invoke method under test for valid plan-id and assert response 116 | response = userPlanMappingRepository.isActivePlan(savedUser.getId(), nonUserPlan.getId()); 117 | assertThat(response).isFalse(); 118 | } 119 | 120 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/repository/UserRepositoryIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.repository; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 8 | 9 | import com.behl.overseer.InitializeMysqlContainer; 10 | import com.behl.overseer.entity.User; 11 | 12 | import net.bytebuddy.utility.RandomString; 13 | 14 | @DataJpaTest 15 | @InitializeMysqlContainer 16 | class UserRepositoryIT { 17 | 18 | @Autowired 19 | private UserRepository userRepository; 20 | 21 | @Test 22 | void shouldReturnTrueIfRecordWithEmailIdExists() { 23 | // insert test user record in datasource 24 | final var emailId = RandomString.make(); 25 | final var password = RandomString.make(); 26 | final var user = new User(); 27 | user.setEmailId(emailId); 28 | user.setPassword(password); 29 | userRepository.save(user); 30 | 31 | // invoke method under test 32 | final var response = userRepository.existsByEmailId(emailId); 33 | 34 | // assert response 35 | assertThat(response).isTrue(); 36 | } 37 | 38 | @Test 39 | void shouldReturnFalseIfRecordDoesNotExistWithEmailId() { 40 | // prepare random invalid emailId 41 | final var emailId = RandomString.make(); 42 | 43 | // invoke method under test 44 | final var response = userRepository.existsByEmailId(emailId); 45 | 46 | // assert response 47 | assertThat(response).isFalse(); 48 | } 49 | 50 | @Test 51 | void shouldFetchUserByEmailId() { 52 | // insert test user record in datasource 53 | final var emailId = RandomString.make(); 54 | final var password = RandomString.make(); 55 | final var user = new User(); 56 | user.setEmailId(emailId); 57 | user.setPassword(password); 58 | userRepository.save(user); 59 | 60 | // invoke method under test 61 | final var fetchedUser = userRepository.findByEmailId(emailId); 62 | 63 | // assert fetched record's attributes 64 | assertThat(fetchedUser).isPresent().get().satisfies(record -> { 65 | assertThat(record.getId()).isNotNull(); 66 | assertThat(record.getEmailId()).isEqualTo(emailId); 67 | assertThat(record.getPassword()).isEqualTo(password); 68 | assertThat(record.getCreatedAt()).isNotNull(); 69 | }); 70 | } 71 | 72 | @Test 73 | void shouldReturnEmptyOptionalWhenFetchingUserWithInvalidEmailId() { 74 | // prepare random invalid emailId 75 | final var emailId = RandomString.make(); 76 | 77 | // invoke method under test 78 | final var fetchedUser = userRepository.findByEmailId(emailId); 79 | 80 | // assert fetched record 81 | assertThat(fetchedUser).isEmpty(); 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/service/PlanServiceIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.when; 7 | 8 | import java.util.UUID; 9 | 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.boot.test.mock.mockito.MockBean; 14 | 15 | import com.behl.overseer.InitializeApplicationSecretKey; 16 | import com.behl.overseer.InitializeMysqlContainer; 17 | import com.behl.overseer.InitializeRedisContainer; 18 | import com.behl.overseer.dto.PlanUpdationRequestDto; 19 | import com.behl.overseer.entity.Plan; 20 | import com.behl.overseer.entity.User; 21 | import com.behl.overseer.entity.UserPlanMapping; 22 | import com.behl.overseer.exception.InvalidPlanException; 23 | import com.behl.overseer.repository.PlanRepository; 24 | import com.behl.overseer.repository.UserPlanMappingRepository; 25 | import com.behl.overseer.repository.UserRepository; 26 | import com.behl.overseer.utility.AuthenticatedUserIdProvider; 27 | 28 | import lombok.Getter; 29 | import lombok.NonNull; 30 | import lombok.RequiredArgsConstructor; 31 | import lombok.Setter; 32 | import net.bytebuddy.utility.RandomString; 33 | 34 | @SpringBootTest 35 | @InitializeRedisContainer 36 | @InitializeMysqlContainer 37 | @InitializeApplicationSecretKey 38 | class PlanServiceIT { 39 | 40 | @Autowired 41 | private PlanService planService; 42 | 43 | @Autowired 44 | private UserRepository userRepository; 45 | 46 | @Autowired 47 | private PlanRepository planRepository; 48 | 49 | @Autowired 50 | private UserPlanMappingRepository userPlanMappingRepository; 51 | 52 | @MockBean 53 | private AuthenticatedUserIdProvider authenticatedUserIdProvider; 54 | 55 | @Test 56 | void planUpdationshouldThrowExceptionForInvalidPlanId() { 57 | // prepare plan updation request with invalid plan-id 58 | final var planId = UUID.randomUUID(); 59 | final var planUpdationRequest = mock(PlanUpdationRequestDto.class); 60 | when(planUpdationRequest.getPlanId()).thenReturn(planId); 61 | 62 | // invoke method under test and assert exception 63 | final var exception = assertThrows(InvalidPlanException.class, () -> planService.update(planUpdationRequest)); 64 | assertThat(exception.getReason()).isEqualTo("No plan exists in the system with provided-id"); 65 | } 66 | 67 | @Test 68 | void datasourceShouldNotBeUpdatedForExistingPlanId() { 69 | // fetch plan records from datasource 70 | final var plans = planRepository.findAll(); 71 | final var userPlan = plans.get(0); 72 | 73 | // populate datasource with test data 74 | final var testData = new TestData().createTestData(userPlan); 75 | final var user = testData.getUser(); 76 | 77 | // prepare plan updation request with existing plan-id 78 | final var planId = userPlan.getId(); 79 | final var planUpdationRequest = mock(PlanUpdationRequestDto.class); 80 | when(planUpdationRequest.getPlanId()).thenReturn(planId); 81 | 82 | // configure authenticated user 83 | final var userId = user.getId(); 84 | when(authenticatedUserIdProvider.getUserId()).thenReturn(userId); 85 | 86 | // invoke method under test 87 | planService.update(planUpdationRequest); 88 | 89 | // assert plan activation status in datasource 90 | final var isActiveWithSamePlan = userPlanMappingRepository.isActivePlan(user.getId(), userPlan.getId()); 91 | assertThat(isActiveWithSamePlan).isTrue(); 92 | } 93 | 94 | @Test 95 | void shouldUpdateUserPlanForValidRequestAndDeactivatePreviousPlan() { 96 | // fetch plan records from datasource 97 | final var plans = planRepository.findAll(); 98 | final var userPlan = plans.get(0); 99 | final var planToUpdate = plans.get(1); 100 | 101 | // populate datasource with test data 102 | final var testData = new TestData().createTestData(userPlan); 103 | final var user = testData.getUser(); 104 | 105 | // prepare plan updation request with valid plan-id 106 | final var planId = planToUpdate.getId(); 107 | final var planUpdationRequest = mock(PlanUpdationRequestDto.class); 108 | when(planUpdationRequest.getPlanId()).thenReturn(planId); 109 | 110 | // configure authenticated user 111 | final var userId = user.getId(); 112 | when(authenticatedUserIdProvider.getUserId()).thenReturn(userId); 113 | 114 | // invoke method under test 115 | planService.update(planUpdationRequest); 116 | 117 | // assert plan activation status in datasource 118 | final var isActiveWithPreviousPlan = userPlanMappingRepository.isActivePlan(user.getId(), userPlan.getId()); 119 | final var isActiveWithNewPlan = userPlanMappingRepository.isActivePlan(user.getId(), planToUpdate.getId()); 120 | assertThat(isActiveWithPreviousPlan).isFalse(); 121 | assertThat(isActiveWithNewPlan).isTrue(); 122 | } 123 | 124 | /** 125 | * @see src/main/resources/db/migration/V002__adding_plans.sql 126 | */ 127 | @Test 128 | void shouldRetrievePlansFromDatasource() { 129 | // invoke method under test 130 | final var plans = planService.retrieve(); 131 | 132 | // assert fetched record's attributes 133 | assertThat(plans).hasSize(3); 134 | plans.forEach(plan -> { 135 | assertThat(plan.getId()).isNotNull(); 136 | assertThat(plan.getName()).isNotNull(); 137 | assertThat(plan.getLimitPerHour()).isNotNull(); 138 | }); 139 | } 140 | 141 | @Getter 142 | @Setter 143 | @RequiredArgsConstructor 144 | class TestData { 145 | 146 | private User user; 147 | private Plan plan; 148 | 149 | public TestData createTestData(@NonNull Plan plan) { 150 | // insert test user record in datasource 151 | final String emailId = RandomString.make(); 152 | final String password = RandomString.make(); 153 | final User user = new User(); 154 | user.setEmailId(emailId); 155 | user.setPassword(password); 156 | final User savedUser = userRepository.save(user); 157 | 158 | // insert an active user plan mapping record 159 | final UserPlanMapping userPlanMapping = new UserPlanMapping(); 160 | userPlanMapping.setUserId(savedUser.getId()); 161 | userPlanMapping.setPlanId(plan.getId()); 162 | userPlanMappingRepository.save(userPlanMapping); 163 | 164 | final var testData = new TestData(); 165 | testData.setUser(savedUser); 166 | testData.setPlan(plan); 167 | return testData; 168 | } 169 | 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/service/PlanServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.Mockito.mock; 7 | import static org.mockito.Mockito.times; 8 | import static org.mockito.Mockito.verify; 9 | import static org.mockito.Mockito.when; 10 | 11 | import java.util.List; 12 | import java.util.UUID; 13 | 14 | import org.junit.jupiter.api.Test; 15 | 16 | import com.behl.overseer.dto.PlanUpdationRequestDto; 17 | import com.behl.overseer.entity.Plan; 18 | import com.behl.overseer.entity.UserPlanMapping; 19 | import com.behl.overseer.exception.InvalidPlanException; 20 | import com.behl.overseer.repository.PlanRepository; 21 | import com.behl.overseer.repository.UserPlanMappingRepository; 22 | import com.behl.overseer.utility.AuthenticatedUserIdProvider; 23 | 24 | import net.bytebuddy.utility.RandomString; 25 | 26 | class PlanServiceTest { 27 | 28 | private final PlanRepository planRepository = mock(PlanRepository.class); 29 | private final RateLimitingService rateLimitingService = mock(RateLimitingService.class); 30 | private final UserPlanMappingRepository userPlanMappingRepository = mock(UserPlanMappingRepository.class); 31 | private final AuthenticatedUserIdProvider authenticatedUserIdProvider = mock(AuthenticatedUserIdProvider.class); 32 | private final PlanService planService = new PlanService(planRepository, rateLimitingService, userPlanMappingRepository, authenticatedUserIdProvider); 33 | 34 | @Test 35 | void planUpdationshouldThrowExceptionForInvalidPlanId() { 36 | // prepare plan updation request 37 | final var planId = UUID.randomUUID(); 38 | final var planUpdationRequest = mock(PlanUpdationRequestDto.class); 39 | when(planUpdationRequest.getPlanId()).thenReturn(planId); 40 | 41 | // prepare datasource to evaluate invalid plan-id 42 | when(planRepository.existsById(planId)).thenReturn(Boolean.FALSE); 43 | 44 | // invoke method under test and assert exception 45 | final var exception = assertThrows(InvalidPlanException.class, () -> planService.update(planUpdationRequest)); 46 | assertThat(exception.getReason()).isEqualTo("No plan exists in the system with provided-id"); 47 | 48 | // verify mock interactions 49 | verify(planUpdationRequest, times(1)).getPlanId(); 50 | verify(planRepository, times(1)).existsById(planId); 51 | verify(userPlanMappingRepository, times(0)).save(any(UserPlanMapping.class)); 52 | } 53 | 54 | @Test 55 | void datasourceShouldNotBeUpdatedForExistingPlanId() { 56 | // prepare plan updation request 57 | final var planId = UUID.randomUUID(); 58 | final var planUpdationRequest = mock(PlanUpdationRequestDto.class); 59 | when(planUpdationRequest.getPlanId()).thenReturn(planId); 60 | 61 | // prepare datasource to evaluate plan-id 62 | when(planRepository.existsById(planId)).thenReturn(Boolean.TRUE); 63 | 64 | // configure authenticated user 65 | final var userId = UUID.randomUUID(); 66 | when(authenticatedUserIdProvider.getUserId()).thenReturn(userId); 67 | 68 | // configure datasource to evaluate active plan 69 | when(userPlanMappingRepository.isActivePlan(userId, planId)).thenReturn(Boolean.TRUE); 70 | 71 | // invoke method under test 72 | planService.update(planUpdationRequest); 73 | 74 | // verify mock interactions 75 | verify(planUpdationRequest, times(1)).getPlanId(); 76 | verify(planRepository, times(1)).existsById(planId); 77 | verify(authenticatedUserIdProvider, times(1)).getUserId(); 78 | verify(userPlanMappingRepository, times(1)).isActivePlan(userId, planId); 79 | verify(userPlanMappingRepository, times(0)).deactivateCurrentPlan(userId); 80 | verify(userPlanMappingRepository, times(0)).save(any(UserPlanMapping.class)); 81 | } 82 | 83 | @Test 84 | void shouldUpdateUserPlanForValidRequestAndDeactivatePreviousPlan() { 85 | // prepare plan updation request 86 | final var planId = UUID.randomUUID(); 87 | final var planUpdationRequest = mock(PlanUpdationRequestDto.class); 88 | when(planUpdationRequest.getPlanId()).thenReturn(planId); 89 | 90 | // prepare datasource to evaluate plan-id 91 | when(planRepository.existsById(planId)).thenReturn(Boolean.TRUE); 92 | 93 | // configure authenticated user 94 | final var userId = UUID.randomUUID(); 95 | when(authenticatedUserIdProvider.getUserId()).thenReturn(userId); 96 | 97 | // configure datasource to evaluate active plan 98 | when(userPlanMappingRepository.isActivePlan(userId, planId)).thenReturn(Boolean.FALSE); 99 | 100 | // invoke method under test 101 | planService.update(planUpdationRequest); 102 | 103 | // verify mock interactions 104 | verify(planUpdationRequest, times(1)).getPlanId(); 105 | verify(planRepository, times(1)).existsById(planId); 106 | verify(authenticatedUserIdProvider, times(1)).getUserId(); 107 | verify(userPlanMappingRepository, times(1)).isActivePlan(userId, planId); 108 | verify(userPlanMappingRepository, times(1)).deactivateCurrentPlan(userId); 109 | verify(userPlanMappingRepository, times(1)).save(any(UserPlanMapping.class)); 110 | verify(rateLimitingService, times(1)).reset(userId); 111 | } 112 | 113 | @Test 114 | void shouldRetrievePlansFromDatasource() { 115 | // prepare plan record 116 | final var planId = UUID.randomUUID(); 117 | final var planName = RandomString.make(); 118 | final var limitPerHour = 20; 119 | final var plan = mock(Plan.class); 120 | when(plan.getId()).thenReturn(planId); 121 | when(plan.getName()).thenReturn(planName); 122 | when(plan.getLimitPerHour()).thenReturn(limitPerHour); 123 | 124 | // configure datasource to return created plan 125 | when(planRepository.findAll()).thenReturn(List.of(plan)); 126 | 127 | // invoke method under test 128 | final var response = planService.retrieve(); 129 | 130 | // assert response 131 | assertThat(response).isNotNull().hasSize(1).satisfies(plans -> { 132 | final var planResponse = plans.get(0); 133 | assertThat(planResponse.getId()).isEqualTo(planId); 134 | assertThat(planResponse.getName()).isEqualTo(planName); 135 | assertThat(planResponse.getLimitPerHour()).isEqualTo(limitPerHour); 136 | }); 137 | 138 | // verify mock interaction 139 | verify(planRepository, times(1)).findAll(); 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/service/RateLimitingServiceIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.Mockito.times; 5 | import static org.mockito.Mockito.verify; 6 | 7 | import org.junit.jupiter.api.Test; 8 | import org.mockito.Mockito; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.boot.test.mock.mockito.SpyBean; 12 | import org.springframework.test.annotation.DirtiesContext; 13 | 14 | import com.behl.overseer.InitializeApplicationSecretKey; 15 | import com.behl.overseer.InitializeMysqlContainer; 16 | import com.behl.overseer.InitializeRedisContainer; 17 | import com.behl.overseer.entity.Plan; 18 | import com.behl.overseer.entity.User; 19 | import com.behl.overseer.entity.UserPlanMapping; 20 | import com.behl.overseer.repository.PlanRepository; 21 | import com.behl.overseer.repository.UserPlanMappingRepository; 22 | import com.behl.overseer.repository.UserRepository; 23 | 24 | import io.github.bucket4j.Bucket; 25 | import lombok.Getter; 26 | import lombok.RequiredArgsConstructor; 27 | import lombok.Setter; 28 | import net.bytebuddy.utility.RandomString; 29 | 30 | @DirtiesContext 31 | @SpringBootTest 32 | @InitializeRedisContainer 33 | @InitializeMysqlContainer 34 | @InitializeApplicationSecretKey 35 | class RateLimitingServiceIT { 36 | 37 | @Autowired 38 | private RateLimitingService rateLimitingService; 39 | 40 | @Autowired 41 | private UserRepository userRepository; 42 | 43 | @Autowired 44 | private PlanRepository planRepository; 45 | 46 | @SpyBean 47 | private UserPlanMappingRepository userPlanMappingRepository; 48 | 49 | @Test 50 | void getBucketOrCreateNewIfNotFoundInCache() { 51 | // prepare test data in datasource 52 | final var testData = new TestData().createTestData(); 53 | final var user = testData.getUser(); 54 | final var plan = testData.getPlan(); 55 | 56 | // invoke method under test 57 | Bucket bucket; 58 | bucket = rateLimitingService.getBucket(user.getId()); 59 | 60 | // assert bucket configuration are equal to user plan 61 | assertThat(bucket.getAvailableTokens()).isEqualTo(Long.valueOf(plan.getLimitPerHour())); 62 | 63 | // verify interaction with datasource to fetch user's active plan 64 | // confirming the creation of bucket configuration from scratch 65 | // on initial invocation 66 | verify(userPlanMappingRepository).getActivePlan(user.getId()); 67 | Mockito.clearInvocations(userPlanMappingRepository); 68 | 69 | // consume tokens from bucket 70 | final var tokensToConsume = 5; 71 | bucket.tryConsume(tokensToConsume); 72 | 73 | // invoke method under test again 74 | bucket = rateLimitingService.getBucket(user.getId()); 75 | 76 | // assert available tokens with bucket 77 | assertThat(bucket.getAvailableTokens()).isEqualTo(plan.getLimitPerHour() - tokensToConsume); 78 | 79 | // assert no interaction with datasource to fetch user's active plan on second 80 | // invocation 81 | verify(userPlanMappingRepository, times(0)).getActivePlan(user.getId()); 82 | } 83 | 84 | @Test 85 | void shouldClearRateLimitConfigurationInCache() { 86 | // prepare test data in datasource 87 | final var testData = new TestData().createTestData(); 88 | final var user = testData.getUser(); 89 | final var plan = testData.getPlan(); 90 | 91 | // create bucket for user 92 | Bucket bucket; 93 | bucket = rateLimitingService.getBucket(user.getId()); 94 | 95 | // assert bucket configuration are equal to user plan 96 | assertThat(bucket.getAvailableTokens()).isEqualTo(Long.valueOf(plan.getLimitPerHour())); 97 | 98 | // consume tokens from bucket and assert available tokens are less than plan 99 | // configuration 100 | final var tokensToConsume = 5; 101 | bucket.tryConsume(tokensToConsume); 102 | assertThat(bucket.getAvailableTokens()).isLessThan(Long.valueOf(plan.getLimitPerHour())); 103 | 104 | // invoke method under test 105 | rateLimitingService.reset(user.getId()); 106 | 107 | // retrieve bucket configuration for user again 108 | bucket = rateLimitingService.getBucket(user.getId()); 109 | 110 | // assert bucket's available token are equal to original plan configuration 111 | assertThat(bucket.getAvailableTokens()).isEqualTo(Long.valueOf(plan.getLimitPerHour())); 112 | } 113 | 114 | @Getter 115 | @Setter 116 | @RequiredArgsConstructor 117 | class TestData { 118 | 119 | private User user; 120 | private Plan plan; 121 | 122 | public TestData createTestData() { 123 | // insert test user record in datasource 124 | final String emailId = RandomString.make(); 125 | final String password = RandomString.make(); 126 | final User user = new User(); 127 | user.setEmailId(emailId); 128 | user.setPassword(password); 129 | final User savedUser = userRepository.save(user); 130 | 131 | // fetch a plan record from datasource 132 | final Plan plan = planRepository.findAll().get(0); 133 | 134 | // insert an active user plan mapping record 135 | final UserPlanMapping userPlanMapping = new UserPlanMapping(); 136 | userPlanMapping.setUserId(savedUser.getId()); 137 | userPlanMapping.setPlanId(plan.getId()); 138 | userPlanMappingRepository.save(userPlanMapping); 139 | 140 | final var testData = new TestData(); 141 | testData.setUser(savedUser); 142 | testData.setPlan(plan); 143 | return testData; 144 | } 145 | 146 | } 147 | 148 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/service/UserServiceIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.when; 7 | 8 | import java.util.UUID; 9 | 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | 15 | import com.behl.overseer.InitializeApplicationSecretKey; 16 | import com.behl.overseer.InitializeMysqlContainer; 17 | import com.behl.overseer.InitializeRedisContainer; 18 | import com.behl.overseer.dto.UserCreationRequestDto; 19 | import com.behl.overseer.dto.UserLoginRequestDto; 20 | import com.behl.overseer.entity.User; 21 | import com.behl.overseer.exception.AccountAlreadyExistsException; 22 | import com.behl.overseer.exception.InvalidLoginCredentialsException; 23 | import com.behl.overseer.exception.InvalidPlanException; 24 | import com.behl.overseer.repository.PlanRepository; 25 | import com.behl.overseer.repository.UserRepository; 26 | 27 | import lombok.Getter; 28 | import lombok.NonNull; 29 | import lombok.RequiredArgsConstructor; 30 | import lombok.Setter; 31 | import net.bytebuddy.utility.RandomString; 32 | 33 | @SpringBootTest 34 | @InitializeRedisContainer 35 | @InitializeMysqlContainer 36 | @InitializeApplicationSecretKey 37 | class UserServiceIT { 38 | 39 | @Autowired 40 | private UserService userService; 41 | 42 | @Autowired 43 | private UserRepository userRepository; 44 | 45 | @Autowired 46 | private PlanRepository planRepository; 47 | 48 | @Autowired 49 | private PasswordEncoder passwordEncoder; 50 | 51 | private static final String JWT_STRUCTURE_REGEX = "^[^.]+\\.[^.]+\\.[^.]+$"; 52 | 53 | @Test 54 | void userCreationShouldThrowExceptionForDuplicateEmailId() { 55 | // insert a user in datasource 56 | final var emailId = RandomString.make() + "@domain.ut"; 57 | final var password = RandomString.make(); 58 | final var user = new User(); 59 | user.setEmailId(emailId); 60 | user.setPassword(password); 61 | userRepository.save(user); 62 | 63 | // prepare user creation request with duplicate email-id 64 | final var userCreationRequest = mock(UserCreationRequestDto.class); 65 | when(userCreationRequest.getEmailId()).thenReturn(emailId); 66 | 67 | // invoke method under test and assert exception 68 | final var exception = assertThrows(AccountAlreadyExistsException.class, 69 | () -> userService.create(userCreationRequest)); 70 | assertThat(exception.getReason()).isEqualTo("Account with provided email-id already exists"); 71 | } 72 | 73 | @Test 74 | void userCreationShouldThrowExceptionForInvalidPlanId() { 75 | // prepare user creation request with random plan-id 76 | final var emailId = RandomString.make() + "@domain.ut"; 77 | final var planId = UUID.randomUUID(); 78 | final var userCreationRequest = mock(UserCreationRequestDto.class); 79 | when(userCreationRequest.getEmailId()).thenReturn(emailId); 80 | when(userCreationRequest.getPlanId()).thenReturn(planId); 81 | 82 | // invoke method under test and verify mock interactions 83 | final var exception = assertThrows(InvalidPlanException.class, () -> userService.create(userCreationRequest)); 84 | assertThat(exception.getReason()).isEqualTo("No plan exists in the system with provided-id"); 85 | } 86 | 87 | @Test 88 | void shouldCreateUserEntityForValidUserCreationRequest() { 89 | // fetch a plan record from datasource 90 | final var plan = planRepository.findAll().get(0); 91 | 92 | // prepare user creation request with valid plan-id 93 | final var emailId = RandomString.make() + "@domain.ut"; 94 | final var password = RandomString.make(); 95 | final var planId = plan.getId(); 96 | final var userCreationRequest = mock(UserCreationRequestDto.class); 97 | when(userCreationRequest.getEmailId()).thenReturn(emailId); 98 | when(userCreationRequest.getPassword()).thenReturn(password); 99 | when(userCreationRequest.getPlanId()).thenReturn(planId); 100 | 101 | // invoke method under test 102 | userService.create(userCreationRequest); 103 | 104 | // assert user record is saved in datasource 105 | final var recordSaved = userRepository.existsByEmailId(emailId); 106 | assertThat(recordSaved).isTrue(); 107 | } 108 | 109 | @Test 110 | void loginShouldThrowExceptionForNonRegisteredEmailId() { 111 | // prepare login request 112 | final var emailId = RandomString.make() + "@domain.ut"; 113 | final var userLoginRequest = mock(UserLoginRequestDto.class); 114 | when(userLoginRequest.getEmailId()).thenReturn(emailId); 115 | 116 | // assert InvalidLoginCredentialsException is thrown for unregistered email-id 117 | assertThrows(InvalidLoginCredentialsException.class, () -> userService.login(userLoginRequest)); 118 | } 119 | 120 | @Test 121 | void loginShouldThrowExceptionForInvalidPassword() { 122 | // create test data in datasource 123 | final var password = RandomString.make(); 124 | final var testData = new TestData().createTestData(password); 125 | final var user = testData.getUser(); 126 | 127 | // prepare login request with wrong password 128 | final var emailId = user.getEmailId(); 129 | final var wrongPassword = RandomString.make(); 130 | final var userLoginRequest = mock(UserLoginRequestDto.class); 131 | when(userLoginRequest.getEmailId()).thenReturn(emailId); 132 | when(userLoginRequest.getPassword()).thenReturn(wrongPassword); 133 | 134 | // assert InvalidLoginCredentialsException is thrown for invalid password 135 | assertThrows(InvalidLoginCredentialsException.class, () -> userService.login(userLoginRequest)); 136 | } 137 | 138 | @Test 139 | void shouldReturnTokenResponseForValidLoginCredentials() { 140 | // create test data in datasource 141 | final var password = RandomString.make(); 142 | final var testData = new TestData().createTestData(password); 143 | final var user = testData.getUser(); 144 | 145 | // prepare valid login request 146 | final var emailId = user.getEmailId(); 147 | final var userLoginRequest = mock(UserLoginRequestDto.class); 148 | when(userLoginRequest.getEmailId()).thenReturn(emailId); 149 | when(userLoginRequest.getPassword()).thenReturn(password); 150 | 151 | // invoke method under test 152 | final var response = userService.login(userLoginRequest); 153 | 154 | // assert response 155 | assertThat(response.getAccessToken()).isNotBlank().matches(JWT_STRUCTURE_REGEX); 156 | } 157 | 158 | @Getter 159 | @Setter 160 | @RequiredArgsConstructor 161 | class TestData { 162 | 163 | private User user; 164 | 165 | public TestData createTestData(@NonNull String planTextPassword) { 166 | // insert test user record in datasource 167 | final String emailId = RandomString.make(); 168 | final String password = passwordEncoder.encode(planTextPassword); 169 | final User user = new User(); 170 | user.setEmailId(emailId); 171 | user.setPassword(password); 172 | final User savedUser = userRepository.save(user); 173 | 174 | final var testData = new TestData(); 175 | testData.setUser(savedUser); 176 | return testData; 177 | } 178 | 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/service/UserServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.service; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.Mockito.mock; 7 | import static org.mockito.Mockito.times; 8 | import static org.mockito.Mockito.verify; 9 | import static org.mockito.Mockito.when; 10 | 11 | import java.util.Optional; 12 | import java.util.UUID; 13 | 14 | import org.junit.jupiter.api.Test; 15 | import org.springframework.security.crypto.password.PasswordEncoder; 16 | 17 | import com.behl.overseer.dto.UserCreationRequestDto; 18 | import com.behl.overseer.dto.UserLoginRequestDto; 19 | import com.behl.overseer.entity.User; 20 | import com.behl.overseer.entity.UserPlanMapping; 21 | import com.behl.overseer.exception.AccountAlreadyExistsException; 22 | import com.behl.overseer.exception.InvalidLoginCredentialsException; 23 | import com.behl.overseer.exception.InvalidPlanException; 24 | import com.behl.overseer.repository.PlanRepository; 25 | import com.behl.overseer.repository.UserPlanMappingRepository; 26 | import com.behl.overseer.repository.UserRepository; 27 | import com.behl.overseer.utility.JwtUtility; 28 | 29 | class UserServiceTest { 30 | 31 | private final JwtUtility jwtUtility = mock(JwtUtility.class); 32 | private final UserRepository userRepository = mock(UserRepository.class); 33 | private final PlanRepository planRepository = mock(PlanRepository.class); 34 | private final PasswordEncoder passwordEncoder = mock(PasswordEncoder.class); 35 | private final UserPlanMappingRepository userPlanMappingRepository = mock(UserPlanMappingRepository.class); 36 | private final UserService userService = new UserService(jwtUtility, userRepository, planRepository, passwordEncoder, 37 | userPlanMappingRepository); 38 | 39 | @Test 40 | void userCreationShouldThrowExceptionForDuplicateEmailId() { 41 | // prepare user creation request 42 | final var emailId = "duplicate@domain.ut"; 43 | final var userCreationRequest = mock(UserCreationRequestDto.class); 44 | when(userCreationRequest.getEmailId()).thenReturn(emailId); 45 | 46 | // set datasource to evaluate duplicate emailid 47 | when(userRepository.existsByEmailId(emailId)).thenReturn(Boolean.TRUE); 48 | 49 | // invoke method under test and assert exception 50 | final var exception = assertThrows(AccountAlreadyExistsException.class, 51 | () -> userService.create(userCreationRequest)); 52 | assertThat(exception.getReason()).isEqualTo("Account with provided email-id already exists"); 53 | 54 | // verify mock interactions 55 | verify(userRepository, times(1)).existsByEmailId(emailId); 56 | verify(userCreationRequest, times(1)).getEmailId(); 57 | verify(userRepository, times(0)).save(any(User.class)); 58 | } 59 | 60 | @Test 61 | void userCreationShouldThrowExceptionForInvalidPlanId() { 62 | // prepare user creation request 63 | final var emailId = "valid@domain.ut"; 64 | final var planId = UUID.randomUUID(); 65 | final var userCreationRequest = mock(UserCreationRequestDto.class); 66 | when(userCreationRequest.getEmailId()).thenReturn(emailId); 67 | when(userCreationRequest.getPlanId()).thenReturn(planId); 68 | 69 | // set datasource to evaluate valid email-id and invalid plan-id 70 | when(userRepository.existsByEmailId(emailId)).thenReturn(Boolean.FALSE); 71 | when(planRepository.existsById(planId)).thenReturn(Boolean.FALSE); 72 | 73 | // invoke method under test and verify mock interactions 74 | final var exception = assertThrows(InvalidPlanException.class, () -> userService.create(userCreationRequest)); 75 | assertThat(exception.getReason()).isEqualTo("No plan exists in the system with provided-id"); 76 | 77 | // verify mock interactions 78 | verify(userCreationRequest, times(1)).getEmailId(); 79 | verify(userCreationRequest, times(1)).getPlanId(); 80 | verify(planRepository, times(1)).existsById(planId); 81 | verify(userRepository, times(0)).save(any(User.class)); 82 | } 83 | 84 | @Test 85 | void shouldCreateUserEntityForValidUserCreationRequest() { 86 | // prepare user creation request 87 | final var emailId = "valid@domain.ut"; 88 | final var password = "valid-password"; 89 | final var planId = UUID.randomUUID(); 90 | final var userCreationRequest = mock(UserCreationRequestDto.class); 91 | when(userCreationRequest.getEmailId()).thenReturn(emailId); 92 | when(userCreationRequest.getPassword()).thenReturn(password); 93 | when(userCreationRequest.getPlanId()).thenReturn(planId); 94 | 95 | // set datasource to evaluate valid request values 96 | when(userRepository.existsByEmailId(emailId)).thenReturn(Boolean.FALSE); 97 | when(planRepository.existsById(planId)).thenReturn(Boolean.TRUE); 98 | 99 | // configure password encoder to encode plan-text password 100 | final var encodedPassword = "encoded-password"; 101 | when(passwordEncoder.encode(password)).thenReturn(encodedPassword); 102 | 103 | // set datasource to save user successfully 104 | when(userRepository.save(any(User.class))).thenReturn(mock(User.class)); 105 | 106 | // invoke method under test 107 | userService.create(userCreationRequest); 108 | 109 | // verify mock interactions 110 | verify(userCreationRequest, times(1)).getEmailId(); 111 | verify(userCreationRequest, times(1)).getPassword(); 112 | verify(userCreationRequest, times(1)).getPlanId(); 113 | 114 | verify(userRepository).existsByEmailId(emailId); 115 | verify(planRepository).existsById(planId); 116 | verify(passwordEncoder).encode(password); 117 | 118 | verify(userRepository, times(1)).save(any(User.class)); 119 | verify(userPlanMappingRepository, times(1)).save(any(UserPlanMapping.class)); 120 | } 121 | 122 | @Test 123 | void loginShouldThrowExceptionForNonRegisteredEmailId() { 124 | // prepare login request 125 | final var emailId = "unregistered@domain.ut"; 126 | final var userLoginRequest = mock(UserLoginRequestDto.class); 127 | when(userLoginRequest.getEmailId()).thenReturn(emailId); 128 | 129 | // set datasource to return no response for unregistered email-id 130 | when(userRepository.findByEmailId(emailId)).thenReturn(Optional.empty()); 131 | 132 | // assert InvalidLoginCredentialsException is thrown for unregistered email-id 133 | assertThrows(InvalidLoginCredentialsException.class, () -> userService.login(userLoginRequest)); 134 | 135 | // verify mock interactions 136 | verify(userLoginRequest, times(1)).getEmailId(); 137 | verify(userRepository, times(1)).findByEmailId(emailId); 138 | } 139 | 140 | @Test 141 | void loginShouldThrowExceptionForInvalidPassword() { 142 | // prepare login request 143 | final var emailId = "mail@domain.ut"; 144 | final var password = "test-password"; 145 | final var userLoginRequest = mock(UserLoginRequestDto.class); 146 | when(userLoginRequest.getEmailId()).thenReturn(emailId); 147 | when(userLoginRequest.getPassword()).thenReturn(password); 148 | 149 | // prepare datasource to return saved user 150 | final var encodedPassword = "test-encoded-password"; 151 | final var user = mock(User.class); 152 | when(user.getPassword()).thenReturn(encodedPassword); 153 | when(userRepository.findByEmailId(emailId)).thenReturn(Optional.of(user)); 154 | 155 | // set password validation to fail 156 | when(passwordEncoder.matches(password, encodedPassword)).thenReturn(Boolean.FALSE); 157 | 158 | // assert InvalidLoginCredentialsException is thrown for invalid password 159 | assertThrows(InvalidLoginCredentialsException.class, () -> userService.login(userLoginRequest)); 160 | 161 | // verify mock interactions 162 | verify(userLoginRequest, times(1)).getEmailId(); 163 | verify(userLoginRequest, times(1)).getPassword(); 164 | verify(user, times(1)).getPassword(); 165 | 166 | verify(userRepository, times(1)).findByEmailId(emailId); 167 | verify(passwordEncoder, times(1)).matches(password, encodedPassword); 168 | } 169 | 170 | @Test 171 | void shouldReturnTokenResponseForValidLoginCredentials() { 172 | // prepare login request 173 | final var emailId = "mail@domain.ut"; 174 | final var password = "test-password"; 175 | final var userLoginRequest = mock(UserLoginRequestDto.class); 176 | when(userLoginRequest.getEmailId()).thenReturn(emailId); 177 | when(userLoginRequest.getPassword()).thenReturn(password); 178 | 179 | // prepare datasource to return saved user 180 | final var encodedPassword = "test-encoded-password"; 181 | final var userId = UUID.randomUUID(); 182 | final var user = mock(User.class); 183 | when(user.getPassword()).thenReturn(encodedPassword); 184 | when(user.getId()).thenReturn(userId); 185 | when(userRepository.findByEmailId(emailId)).thenReturn(Optional.of(user)); 186 | 187 | // set password validation to pass 188 | when(passwordEncoder.matches(password, encodedPassword)).thenReturn(Boolean.TRUE); 189 | 190 | // set token generation 191 | final var accessToken = "test-access-token"; 192 | when(jwtUtility.generateAccessToken(userId)).thenReturn(accessToken); 193 | 194 | // invoke method under test 195 | final var response = userService.login(userLoginRequest); 196 | 197 | // assert response contains generated access-token 198 | assertThat(response.getAccessToken()).isEqualTo(accessToken); 199 | 200 | // verify mock interactions 201 | verify(userLoginRequest, times(1)).getEmailId(); 202 | verify(userLoginRequest, times(1)).getPassword(); 203 | verify(user, times(1)).getPassword(); 204 | 205 | verify(userRepository, times(1)).findByEmailId(emailId); 206 | verify(passwordEncoder, times(1)).matches(password, encodedPassword); 207 | verify(jwtUtility, times(1)).generateAccessToken(userId); 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/utility/ApiEndpointSecurityInspectorIT.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.when; 6 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 7 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 8 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 9 | 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.http.HttpMethod; 15 | import org.springframework.http.HttpStatus; 16 | import org.springframework.http.ResponseEntity; 17 | import org.springframework.test.web.servlet.MockMvc; 18 | import org.springframework.web.bind.annotation.GetMapping; 19 | import org.springframework.web.bind.annotation.RestController; 20 | 21 | import com.behl.overseer.InitializeApplicationSecretKey; 22 | import com.behl.overseer.InitializeMysqlContainer; 23 | import com.behl.overseer.InitializeRedisContainer; 24 | import com.behl.overseer.configuration.PublicEndpoint; 25 | 26 | import jakarta.servlet.http.HttpServletRequest; 27 | import lombok.SneakyThrows; 28 | 29 | @RestController 30 | class TestController { 31 | 32 | @PublicEndpoint 33 | @GetMapping(value = "/api/v1/public-endpoint") 34 | public ResponseEntity publicEndpoint() { 35 | return ResponseEntity.ok().build(); 36 | } 37 | 38 | @GetMapping(value = "/api/v1/private-endpoint") 39 | public ResponseEntity privateEndpoint() { 40 | return ResponseEntity.ok().build(); 41 | } 42 | 43 | } 44 | 45 | @SpringBootTest 46 | @AutoConfigureMockMvc 47 | @InitializeRedisContainer 48 | @InitializeMysqlContainer 49 | @InitializeApplicationSecretKey 50 | class ApiEndpointSecurityInspectorIT { 51 | 52 | @Autowired 53 | private MockMvc mockMvc; 54 | 55 | @Autowired 56 | private ApiEndpointSecurityInspector apiEndpointSecurityInspector; 57 | 58 | @Test 59 | void shouldReturnTrueIfHttpRequestDirectedTowardsUnsecuredApiEndpoint() { 60 | // defining API path to invoke 61 | final var apiPath = "/api/v1/public-endpoint"; 62 | 63 | // simulating incoming HTTP request 64 | final var httpRequest = mock(HttpServletRequest.class); 65 | when(httpRequest.getMethod()).thenReturn(HttpMethod.GET.name()); 66 | when(httpRequest.getRequestURI()).thenReturn(apiPath); 67 | 68 | // invoke method under test 69 | final var result = apiEndpointSecurityInspector.isUnsecureRequest(httpRequest); 70 | 71 | // assert response 72 | assertThat(result).isTrue(); 73 | } 74 | 75 | @Test 76 | void shouldReturnFalseIfHttpRequestDirectedTowardsSecuredApiEndpoint() { 77 | // defining API path to invoke 78 | final var apiPath = "/api/v1/private-endpoint"; 79 | 80 | // simulating incoming HTTP request 81 | final var httpRequest = mock(HttpServletRequest.class); 82 | when(httpRequest.getMethod()).thenReturn(HttpMethod.GET.name()); 83 | when(httpRequest.getRequestURI()).thenReturn(apiPath); 84 | 85 | // invoke method under test 86 | final var result = apiEndpointSecurityInspector.isUnsecureRequest(httpRequest); 87 | 88 | // assert response 89 | assertThat(result).isFalse(); 90 | } 91 | 92 | @Test 93 | @SneakyThrows 94 | void publicEndpointShouldBeAccessibleWithoutAuthToken() { 95 | // invoke public-endpoint without auth token and assert response 96 | final var apiPath = "/api/v1/public-endpoint"; 97 | mockMvc.perform(get(apiPath)) 98 | .andExpect(status().isOk()); 99 | } 100 | 101 | @Test 102 | @SneakyThrows 103 | void privateEndpointShouldBeInaccessibleWithoutAuthToken() { 104 | // invoke private-endpoint without auth token and assert response 105 | final var apiPath = "/api/v1/private-endpoint"; 106 | mockMvc.perform(get(apiPath)) 107 | .andExpect(status().isUnauthorized()) 108 | .andExpect(jsonPath("$.Status").value(HttpStatus.UNAUTHORIZED.toString())) 109 | .andExpect(jsonPath("$.Description").value("Authentication failure: Token missing, invalid or expired")); 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/utility/AuthenticatedUserIdProviderTest.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.verify; 7 | import static org.mockito.Mockito.when; 8 | 9 | import java.util.UUID; 10 | 11 | import org.junit.jupiter.api.Test; 12 | import org.springframework.security.core.Authentication; 13 | import org.springframework.security.core.context.SecurityContext; 14 | import org.springframework.security.core.context.SecurityContextHolder; 15 | import org.springframework.security.core.userdetails.UserDetails; 16 | 17 | class AuthenticatedUserIdProviderTest { 18 | 19 | private final AuthenticatedUserIdProvider authenticatedUserIdProvider = new AuthenticatedUserIdProvider(); 20 | 21 | @Test 22 | void shouldExtractUserIdOfAuthenticatedUserFromSecurityPrincipal() { 23 | // preparing security context to hold userId as principal 24 | final var userId = UUID.randomUUID(); 25 | final var securityContext = mock(SecurityContext.class); 26 | final var authentication = mock(Authentication.class); 27 | when(authentication.getPrincipal()).thenReturn(userId); 28 | when(securityContext.getAuthentication()).thenReturn(authentication); 29 | SecurityContextHolder.setContext(securityContext); 30 | 31 | final var response = authenticatedUserIdProvider.getUserId(); 32 | 33 | assertThat(response).isInstanceOf(UUID.class).isEqualTo(userId); 34 | verify(securityContext).getAuthentication(); 35 | verify(authentication).getPrincipal(); 36 | } 37 | 38 | @Test 39 | void shouldThrowIllegalStateExceptionForMissingSecurityContext() { 40 | SecurityContextHolder.clearContext(); 41 | assertThrows(IllegalStateException.class, authenticatedUserIdProvider::getUserId); 42 | } 43 | 44 | @Test 45 | void shouldThrowIllegalStateExceptionWhenPrincipalNonUUID() { 46 | // preparing security context to hold UserDetails as principal 47 | final var userDetails = mock(UserDetails.class); 48 | final var securityContext = mock(SecurityContext.class); 49 | final var authentication = mock(Authentication.class); 50 | when(authentication.getPrincipal()).thenReturn(userDetails); 51 | when(securityContext.getAuthentication()).thenReturn(authentication); 52 | SecurityContextHolder.setContext(securityContext); 53 | 54 | assertThrows(IllegalStateException.class, authenticatedUserIdProvider::getUserId); 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/test/java/com/behl/overseer/utility/JwtUtilityTest.java: -------------------------------------------------------------------------------- 1 | package com.behl.overseer.utility; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.verify; 7 | import static org.mockito.Mockito.when; 8 | 9 | import java.util.UUID; 10 | 11 | import org.junit.jupiter.api.Test; 12 | 13 | import com.behl.overseer.configuration.TokenConfigurationProperties; 14 | 15 | import io.jsonwebtoken.Jwts; 16 | import io.jsonwebtoken.io.Encoders; 17 | 18 | class JwtUtilityTest { 19 | 20 | private static final String JWT_STRUCTURE_REGEX = "^[^.]+\\.[^.]+\\.[^.]+$"; 21 | 22 | private final TokenConfigurationProperties tokenConfigurationProperties = mock(TokenConfigurationProperties.class); 23 | private final static String issuer = "unit-test-issuer"; 24 | private final JwtUtility jwtUtility = new JwtUtility(issuer, tokenConfigurationProperties); 25 | 26 | @Test 27 | void shouldGenerateValidAccessTokenForUserEntityWithRequiredClaims() { 28 | // Prepare test user id 29 | final var userId = UUID.randomUUID(); 30 | 31 | // configure token configuration 32 | final var accessTokenValidity = 1; 33 | final var secretKey = Encoders.BASE64.encode(Jwts.SIG.HS256.key().build().getEncoded()); 34 | when(tokenConfigurationProperties.getSecretKey()).thenReturn(secretKey); 35 | when(tokenConfigurationProperties.getValidity()).thenReturn(accessTokenValidity); 36 | 37 | // Generate access token for user entity 38 | final var accessToken = jwtUtility.generateAccessToken(userId); 39 | 40 | // Validate the generated access token and verify mock interactions 41 | assertThat(accessToken).isNotBlank().matches(JWT_STRUCTURE_REGEX); 42 | verify(tokenConfigurationProperties).getSecretKey(); 43 | verify(tokenConfigurationProperties).getValidity(); 44 | 45 | // Extract user-id from generated access token 46 | final var extractedUserId = jwtUtility.getUserId(accessToken); 47 | 48 | // Assert validity of extracted user ID 49 | assertThat(extractedUserId).isNotNull().isInstanceOf(UUID.class).isEqualTo(userId); 50 | } 51 | 52 | @Test 53 | void shouldThrowIllegalArgumentExceptionForNullArguments() { 54 | assertThrows(IllegalArgumentException.class, () -> jwtUtility.getUserId(null)); 55 | assertThrows(IllegalArgumentException.class, () -> jwtUtility.generateAccessToken(null)); 56 | } 57 | 58 | } --------------------------------------------------------------------------------