├── .github └── workflows │ └── dev-branch-pr-deployment-pipeline.yml ├── .gitignore ├── Dockerfile ├── README.md ├── build.gradle.kts ├── gradlew ├── gradlew.bat ├── logback-spring.xml ├── settings.gradle.kts └── src └── main ├── kotlin └── com │ └── zufar │ └── urlshortener │ ├── UrlShortenerApplication.kt │ ├── auth │ ├── controller │ │ └── AuthController.kt │ ├── dto │ │ ├── AuthResponse.kt │ │ ├── ErrorMessage.kt │ │ ├── RefreshTokenRequest.kt │ │ ├── RefreshTokenResponse.kt │ │ ├── SignInRequest.kt │ │ └── SignUpRequest.kt │ ├── entity │ │ └── UserDetails.kt │ ├── exception │ │ ├── EmailAlreadyExistsException.kt │ │ ├── InvalidTokenException.kt │ │ └── UserNotFoundException.kt │ ├── repository │ │ └── UserRepository.kt │ └── service │ │ ├── CustomUserDetailsService.kt │ │ ├── JwtAuthenticationFilter.kt │ │ ├── JwtTokenProvider.kt │ │ └── validator │ │ ├── AuthRequestValidator.kt │ │ ├── EmailOfUserValidator.kt │ │ └── PasswordOfUserValidator.kt │ ├── common │ ├── config │ │ ├── OpenAPIConfig.kt │ │ └── SecurityConfig.kt │ └── exception │ │ ├── ErrorResponse.kt │ │ ├── GlobalExceptionHandler.kt │ │ └── InvalidRequestException.kt │ └── shorten │ ├── controller │ ├── UrlController.kt │ ├── UrlRedirectController.kt │ └── UserProviderController.kt │ ├── dto │ ├── ShortenUrlRequest.kt │ ├── UrlMappingDto.kt │ ├── UrlMappingPageDto.kt │ ├── UrlResponse.kt │ └── UserDetailsDto.kt │ ├── entity │ └── UrlMapping.kt │ ├── exception │ └── UrlNotFoundException.kt │ ├── repository │ └── UrlRepository.kt │ └── service │ ├── CorrelationIdFilter.kt │ ├── DaysCountValidator.kt │ ├── PageableUrlMappingsProvider.kt │ ├── StringEncoder.kt │ ├── UrlDeleter.kt │ ├── UrlExpirationTimeDeletionScheduler.kt │ ├── UrlMappingEntityCreator.kt │ ├── UrlMappingProvider.kt │ ├── UrlShortener.kt │ ├── UrlValidator.kt │ └── UserDetailsProvider.kt └── resources └── application.properties /.github/workflows/dev-branch-pr-deployment-pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - development 8 | 9 | jobs: 10 | build-and-push-docker-image: 11 | name: Build and push a new docker image 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout the source code from the Github repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Login to Dockerhub 18 | uses: docker/login-action@v3 19 | with: 20 | username: ${{ vars.DOCKER_HUB_ACCOUNT_NAME }} 21 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 22 | 23 | - name: Build a new docker image 24 | run: docker buildx build --platform linux/amd64 -t ${{ vars.DOCKER_HUB_ACCOUNT_NAME }}/${{ vars.DOCKER_IMAGE_NAME }}:latest-version --push . 25 | 26 | deploy-app-to-server: 27 | name: Deploy application to remote server via SSH 28 | runs-on: ubuntu-latest 29 | needs: build-and-push-docker-image 30 | steps: 31 | - name: Set DOCKER_IMAGE_TAG 32 | run: echo "DOCKER_IMAGE_TAG=latest-version" >> $GITHUB_ENV 33 | 34 | - name: Deploy image via SSH 35 | uses: appleboy/ssh-action@v1.0.0 36 | with: 37 | host: ${{ secrets.DEV_SSH_HOST }} 38 | port: ${{ secrets.DEV_SSH_PORT }} 39 | username: ${{ secrets.DEV_SSH_USER }} 40 | password: ${{ secrets.DEV_SSH_PASSWORD }} 41 | script: | 42 | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u ${{ vars.DOCKER_HUB_ACCOUNT_NAME }} --password-stdin 43 | cd /home/ec2-user/url-shortener 44 | docker-compose pull 45 | docker-compose up -d 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !.gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | ### Kotlin ### 40 | .kotlin 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build Stage 2 | FROM gradle:8.4-jdk21 AS build 3 | WORKDIR /app 4 | COPY build.gradle.kts settings.gradle.kts ./ 5 | COPY src ./src 6 | RUN gradle build --no-daemon -x test 7 | 8 | # Run Stage 9 | FROM eclipse-temurin:21-jdk-alpine 10 | WORKDIR /app 11 | COPY --from=build /app/build/libs/*.jar app.jar 12 | EXPOSE 8080 13 | ENTRYPOINT ["java", "-jar", "/app/app.jar"] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |

URL Shortener

5 | 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Sunagatov/URL-Shortener/blob/main/LICENSE) 7 | [![GitHub issues](https://img.shields.io/github/issues/Sunagatov/URL-Shortener)](https://github.com/Sunagatov/URL-Shortener/issues) 8 | [![Total Lines of Code](https://tokei.rs/b1/github/Sunagatov/URL-Shortener?category=lines)](https://github.com/Sunagatov/URL-Shortener) 9 | [![Code Coverage](https://codecov.io/github/Sunagatov/URL-Shortener/branch/main/graph/badge.svg?token=your-token)](https://app.codecov.io/github/Sunagatov/URL-Shortener) 10 | 11 | [![GitHub contributors](https://img.shields.io/github/contributors/Sunagatov/URL-Shortener)](https://github.com/Sunagatov/URL-Shortener/graphs/contributors) 12 | [![GitHub stars](https://img.shields.io/github/stars/Sunagatov/URL-Shortener)](https://github.com/Sunagatov/URL-Shortener/stargazers) 13 | [![Fork on GitHub](https://img.shields.io/github/forks/Sunagatov/URL-Shortener.svg?style=social)](https://github.com/Sunagatov/URL-Shortener/network/members) 14 |
15 | 16 | ## Table of Contents 17 | - [Introduction](#introduction) 18 | - [GitHub Stars](#github-stars) 19 | - [Tech Stack](#tech-stack) 20 | - [Quick Start](#quick-start) 21 | - [Deployment](#deployment) 22 | - [Forking and Tweaking](#forking-and-tweaking) 23 | - [Reporting a Bug](#reporting-a-bug) 24 | - [Proposing a New Feature](#proposing-a-new-feature) 25 | - [Contributions](#contributions) 26 | - [Code of Conduct](#code-of-conduct) 27 | - [Top Contributors](#top-contributors) 28 | - [License](#license) 29 | - [Contact](#contact) 30 | 31 | ## Introduction 32 | 33 | **🟥 URL Shortener** is a non-profit project aimed at providing a robust URL shortening service. Our goal is to enhance technical skills and collaborate on an engaging project. 34 | 35 | 🟥 We operate without financial backing or compensation; our efforts are driven by passion and commitment. 36 | 37 | ### 🔥 GitHub Stars 38 | 39 | Please support the URL Shortener project by giving stars 🌟 on GitHub repositories - your ratings mean a lot to us! 🙂 40 | 41 | ## Tech Stack 42 | 43 | - **Architecture:** Monolith. 44 | - **Programming Language:** Kotlin. 45 | - **Framework:** Spring Boot 3, Spring Data MongoDB, Spring Security, Spring Actuator, Spring Quartz, Spring Batch. 46 | - **Database:** MongoDB. 47 | - **Caching:** Caffeine. 48 | - **Email:** SMTP. 49 | - **Monitoring:** Prometheus. 50 | - **Logging:** Logback. 51 | - **Testing:** JUnit 5, Testcontainers, Rest Assured. 52 | - **API Specs:** Open API. 53 | - **Validation:** Javax Validation. 54 | 55 | ## Quick Start 56 | 57 | Follow the setup instructions in [START.MD](START.md) to get the project up and running. 58 | 59 | ## 🚢 Deployment 60 | 61 | Deployment is managed using Docker Compose. You can find the production configuration in the [docker-compose.yml](docker-compose.yml) file. 62 | 63 | GitHub Actions handle continuous integration and deployment. Check out the [CI pipeline](.github/workflows/ci.yml) for details. 64 | 65 | ## 🛤 Forking and Tweaking 66 | 67 | Forks are welcome! 68 | 69 | Please: 70 | - Share new features you implement so others can benefit and your codebase stays in sync with the original. 71 | - Use [Discussions](https://github.com/Sunagatov/URL-Shortener/discussions) for support rather than official channels. 72 | 73 | ## 🙋‍♂️ Reporting a Bug 74 | 75 | - 🆕 Open [a new issue](https://github.com/Sunagatov/URL-Shortener/issues/new). 76 | - 🔦 **Search first** to see if the issue already exists! 77 | - Provide detailed descriptions of the observed and expected behavior. 78 | 79 | ## 💎 Proposing a New Feature 80 | 81 | - Visit our [Discussions](https://github.com/Sunagatov/URL-Shortener/discussions) 82 | - Check if the feature has been proposed already. 83 | - Create a new discussion with detailed requirements. 84 | 85 | ## 😍 Contributions 86 | 87 | Contributions are welcome! 88 | 89 | - Check the [Issues page](https://github.com/Sunagatov/URL-Shortener/issues) for current tasks. 90 | - Comment on issues you're interested in working on. 91 | 92 | For major changes, open an issue first or discuss in comments to avoid logical contradictions. 93 | 94 | ### 🚦 Issue Labels 95 | 96 | #### 🟩 Ready to Implement 97 | - **good first issue** — Great for newcomers. 98 | - **bug** — Issues that need fixing. 99 | - **high priority** — Urgent tickets. 100 | - **enhancement** — Improvements to existing features. 101 | 102 | #### 🟨 Discussion Needed 103 | - **new feature** — New, complex features. 104 | - **idea** — Ideas requiring discussion. 105 | 106 | #### 🟥 Questionable 107 | - **¯\\_(ツ)_/¯** — Questionable issues, should be reviewed. 108 | - **[no label]** — New or unclear tickets. 109 | 110 | ## 👍 Top Contributors 111 | 112 | Recognize our top contributors who make this project better: 113 | 114 | #### 😎 Project Creator / Tech Lead 115 | - [@Sunagatov](https://github.com/Sunagatov) 116 | 117 | #### ⚙️ Backend Developers 118 | - [@Sunagatov](https://github.com/Sunagatov) 119 | - [@annstriganova](https://github.com/annstriganova) 120 | 121 | 122 | ## 👩‍💼 License 123 | 124 | [MIT](LICENSE) 125 | 126 | You can use the code for private and commercial purposes with appropriate attribution. 127 | 128 | ## 📞 Contact 129 | 130 | Join our community on [Telegram](https://t.me/zufarexplained). 131 | 132 | Feel free to reach out via email: [zufar.sunagatov@gmail.com](mailto:zufar.sunagatov@gmail.com). 133 | 134 | ❤️ 135 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") version "1.9.25" 3 | kotlin("plugin.spring") version "1.9.25" 4 | id("org.springframework.boot") version "3.3.3" 5 | id("io.spring.dependency-management") version "1.1.6" 6 | id("org.sonarqube") version "5.0.0.4638" 7 | jacoco 8 | } 9 | 10 | group = "com.zufar" 11 | version = "0.0.1" 12 | 13 | java { 14 | toolchain { 15 | languageVersion = JavaLanguageVersion.of(21) 16 | } 17 | } 18 | 19 | repositories { 20 | mavenCentral() 21 | } 22 | 23 | sonar { 24 | properties { 25 | property("sonar.projectKey", "shorty-url") 26 | property("sonar.projectName", "ShortyURL") 27 | } 28 | } 29 | 30 | val springCloudVersion = "2023.0.3" 31 | val mockitoVersion = "5.13.0" 32 | val mockitoKotlinVersion = "5.4.0" 33 | val springdocVersion = "2.6.0" 34 | val javaxValidationApiVersion = "2.0.1.Final" 35 | val commonsValidatorVersion = "1.9.0" 36 | 37 | val jjwtApiVersion = "0.11.5" 38 | 39 | 40 | dependencies { 41 | // Spring Boot MVC 42 | implementation("org.springframework.boot:spring-boot-starter-web") 43 | implementation("org.springframework.boot:spring-boot-starter-actuator") 44 | 45 | // Database 46 | implementation("org.springframework.boot:spring-boot-starter-data-mongodb") 47 | 48 | // Security 49 | implementation("org.springframework.boot:spring-boot-starter-security") 50 | 51 | // JWT 52 | implementation("io.jsonwebtoken:jjwt-api:$jjwtApiVersion") 53 | runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtApiVersion") 54 | runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtApiVersion") 55 | 56 | // Password Encoding 57 | implementation("org.springframework.security:spring-security-crypto") 58 | 59 | // Logging 60 | implementation("org.springframework.boot:spring-boot-starter-logging") 61 | 62 | // Jackson 63 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 64 | implementation("org.jetbrains.kotlin:kotlin-reflect") 65 | 66 | // OpenAPI 67 | implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") 68 | implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:$springdocVersion") 69 | 70 | // Validation 71 | implementation("org.springframework.boot:spring-boot-starter-validation") 72 | implementation("javax.validation:validation-api:$javaxValidationApiVersion") 73 | implementation("commons-validator:commons-validator:$commonsValidatorVersion") 74 | 75 | // Lombok 76 | compileOnly("org.projectlombok:lombok") 77 | annotationProcessor("org.projectlombok:lombok") 78 | 79 | // Testing 80 | testImplementation("org.springframework.boot:spring-boot-starter-test") 81 | testImplementation("org.mockito:mockito-core:$mockitoVersion") 82 | testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion") 83 | testImplementation("org.jetbrains.kotlin:kotlin-test") 84 | } 85 | 86 | dependencyManagement { 87 | imports { 88 | mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion") 89 | } 90 | } 91 | 92 | kotlin { 93 | compilerOptions { 94 | freeCompilerArgs.addAll("-Xjsr305=strict") 95 | } 96 | } 97 | 98 | tasks.withType { 99 | useJUnitPlatform() 100 | } 101 | 102 | sonarqube { 103 | properties { 104 | property("sonar.projectKey", "Sunagatov_URL-Shortener") 105 | property("sonar.organization", "zufar") 106 | property("sonar.host.url", "https://sonarcloud.io") 107 | property("sonar.login", System.getenv("SHORTY_URL_SONAR_TOKEN")) 108 | property("sonar.sources", "src/main/kotlin") 109 | property("sonar.tests", "src/test/kotlin") 110 | property("sonar.language", "kotlin") 111 | property("sonar.kotlin.detekt.reportPaths", "build/reports/detekt") 112 | property("sonar.jacoco.reportPaths", "${layout.buildDirectory}/jacoco/test.exec") 113 | } 114 | } 115 | 116 | tasks.named("sonarqube") { 117 | dependsOn("jacocoTestReport") // Ensure JaCoCo report is generated before SonarCloud analysis 118 | } 119 | 120 | sourceSets { 121 | main { 122 | kotlin { 123 | srcDirs("${layout.buildDirectory}/generated/api/src/main/kotlin") 124 | } 125 | } 126 | } 127 | 128 | jacoco { 129 | toolVersion = "0.8.10" 130 | } 131 | tasks.jacocoTestReport { 132 | reports { 133 | xml.required.set(true) 134 | html.required.set(true) 135 | } 136 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{yyyy-MM-dd HH:mm:ss.SSS} - correlationId=%X{correlationId} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "URL-Shortener" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/UrlShortenerApplication.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition 4 | import io.swagger.v3.oas.annotations.info.Info 5 | import org.springframework.boot.autoconfigure.SpringBootApplication 6 | import org.springframework.boot.runApplication 7 | 8 | @OpenAPIDefinition( 9 | info = Info( 10 | title = "URL Shortener API", 11 | version = "1.0", 12 | description = "API documentation for URL Shortener" 13 | ) 14 | ) 15 | @SpringBootApplication 16 | class UrlShortenerApplication 17 | 18 | fun main(args: Array) { 19 | runApplication(*args) 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/controller/AuthController.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.controller 2 | 3 | import com.zufar.urlshortener.auth.dto.* 4 | import com.zufar.urlshortener.auth.entity.UserDetails 5 | import com.zufar.urlshortener.auth.exception.EmailAlreadyExistsException 6 | import com.zufar.urlshortener.auth.exception.InvalidTokenException 7 | import com.zufar.urlshortener.auth.exception.UserNotFoundException 8 | import com.zufar.urlshortener.auth.repository.UserRepository 9 | import com.zufar.urlshortener.auth.service.JwtTokenProvider 10 | import com.zufar.urlshortener.auth.service.validator.AuthRequestValidator 11 | import com.zufar.urlshortener.common.exception.ErrorResponse 12 | import io.swagger.v3.oas.annotations.Operation 13 | import io.swagger.v3.oas.annotations.media.Content 14 | import io.swagger.v3.oas.annotations.media.ExampleObject 15 | import io.swagger.v3.oas.annotations.media.Schema 16 | import io.swagger.v3.oas.annotations.responses.ApiResponse 17 | import io.swagger.v3.oas.annotations.responses.ApiResponses 18 | import io.swagger.v3.oas.annotations.tags.Tag 19 | import io.swagger.v3.oas.annotations.parameters.RequestBody as SwaggerRequestBody 20 | import org.springframework.http.ResponseEntity 21 | import org.springframework.security.authentication.AuthenticationManager 22 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 23 | import org.springframework.security.core.userdetails.User 24 | import org.springframework.security.crypto.password.PasswordEncoder 25 | import org.springframework.web.bind.annotation.* 26 | import java.time.LocalDateTime 27 | 28 | @RestController 29 | @RequestMapping("/api/v1/auth") 30 | @Tag( 31 | name = "Authentication", 32 | description = "Endpoints for user authentication, registration, and token management." 33 | ) 34 | class AuthController( 35 | private val authenticationManager: AuthenticationManager, 36 | private val jwtTokenProvider: JwtTokenProvider, 37 | private val authRequestValidator: AuthRequestValidator, 38 | private val userRepository: UserRepository, 39 | private val passwordEncoder: PasswordEncoder 40 | ) { 41 | 42 | @Operation( 43 | summary = "User Sign-in", 44 | description = "Authenticate a user with their email and password to obtain access and refresh JWT tokens." 45 | ) 46 | @ApiResponses( 47 | value = [ 48 | ApiResponse( 49 | responseCode = "200", 50 | description = "Successfully authenticated user.", 51 | content = [ 52 | Content( 53 | mediaType = "application/json", 54 | schema = Schema(implementation = AuthResponse::class), 55 | examples = [ 56 | ExampleObject( 57 | name = "SuccessResponse", 58 | summary = "Successful Authentication", 59 | value = """ 60 | { 61 | "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", 62 | "refreshToken": "dGhpc0lzQVJlZnJlc2hUb2tlbi..." 63 | } 64 | """ 65 | ) 66 | ] 67 | ) 68 | ] 69 | ), 70 | ApiResponse( 71 | responseCode = "400", 72 | description = "Invalid request data.", 73 | content = [ 74 | Content( 75 | mediaType = "application/json", 76 | schema = Schema(implementation = ErrorResponse::class), 77 | examples = [ 78 | ExampleObject( 79 | name = "InvalidRequest", 80 | summary = "Missing or invalid fields", 81 | value = """ 82 | { 83 | "errorMessage": "Email and password must not be empty." 84 | } 85 | """ 86 | ) 87 | ] 88 | ) 89 | ] 90 | ), 91 | ApiResponse( 92 | responseCode = "401", 93 | description = "Invalid credentials.", 94 | content = [ 95 | Content( 96 | mediaType = "application/json", 97 | schema = Schema(implementation = ErrorResponse::class), 98 | examples = [ 99 | ExampleObject( 100 | name = "Unauthorized", 101 | summary = "Wrong email or password", 102 | value = """ 103 | { 104 | "errorMessage": "Invalid email or password." 105 | } 106 | """ 107 | ) 108 | ] 109 | ) 110 | ] 111 | ) 112 | ] 113 | ) 114 | @PostMapping("/signin") 115 | fun authenticateUser( 116 | @SwaggerRequestBody( 117 | description = "User's login credentials.", 118 | required = true, 119 | content = [ 120 | Content( 121 | mediaType = "application/json", 122 | schema = Schema(implementation = SignInRequest::class), 123 | examples = [ 124 | ExampleObject( 125 | name = "SignInExample", 126 | summary = "Example SignInRequest", 127 | value = """ 128 | { 129 | "email": "user@example.com", 130 | "password": "SecurePassword123!" 131 | } 132 | """ 133 | ) 134 | ] 135 | ) 136 | ] 137 | ) 138 | @RequestBody signInRequest: SignInRequest 139 | ): ResponseEntity { 140 | authRequestValidator.validateAuthRequest(signInRequest) 141 | 142 | val authentication = authenticationManager.authenticate( 143 | UsernamePasswordAuthenticationToken( 144 | signInRequest.email, 145 | signInRequest.password 146 | ) 147 | ) 148 | 149 | val userDetails = authentication.principal as User 150 | 151 | val accessToken = jwtTokenProvider.generateAccessToken(userDetails) 152 | val refreshToken = jwtTokenProvider.generateRefreshToken(userDetails) 153 | 154 | return ResponseEntity.ok(AuthResponse(accessToken, refreshToken)) 155 | } 156 | 157 | @Operation( 158 | summary = "User Sign-up", 159 | description = "Register a new user and obtain access and refresh JWT tokens." 160 | ) 161 | @ApiResponses( 162 | value = [ 163 | ApiResponse( 164 | responseCode = "200", 165 | description = "User registered successfully.", 166 | content = [ 167 | Content( 168 | mediaType = "application/json", 169 | schema = Schema(implementation = AuthResponse::class), 170 | examples = [ 171 | ExampleObject( 172 | name = "SuccessResponse", 173 | summary = "Successful Registration", 174 | value = """ 175 | { 176 | "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", 177 | "refreshToken": "dGhpc0lzQVJlZnJlc2hUb2tlbi..." 178 | } 179 | """ 180 | ) 181 | ] 182 | ) 183 | ] 184 | ), 185 | ApiResponse( 186 | responseCode = "400", 187 | description = "Invalid request data.", 188 | content = [ 189 | Content( 190 | mediaType = "application/json", 191 | schema = Schema(implementation = ErrorResponse::class), 192 | examples = [ 193 | ExampleObject( 194 | name = "InvalidRequest", 195 | summary = "Missing or invalid fields", 196 | value = """ 197 | { 198 | "errorMessage": "Required fields are missing or invalid." 199 | } 200 | """ 201 | ) 202 | ] 203 | ) 204 | ] 205 | ), 206 | ApiResponse( 207 | responseCode = "409", 208 | description = "Email already in use.", 209 | content = [ 210 | Content( 211 | mediaType = "application/json", 212 | schema = Schema(implementation = ErrorResponse::class), 213 | examples = [ 214 | ExampleObject( 215 | name = "EmailExists", 216 | summary = "Email already registered", 217 | value = """ 218 | { 219 | "errorMessage": "Email is already in use." 220 | } 221 | """ 222 | ) 223 | ] 224 | ) 225 | ] 226 | ) 227 | ] 228 | ) 229 | @PostMapping("/signup") 230 | fun registerUser( 231 | @SwaggerRequestBody( 232 | description = "New user's registration details.", 233 | required = true, 234 | content = [ 235 | Content( 236 | mediaType = "application/json", 237 | schema = Schema(implementation = SignUpRequest::class), 238 | examples = [ 239 | ExampleObject( 240 | name = "SignUpExample", 241 | summary = "Example SignUpRequest", 242 | value = """ 243 | { 244 | "firstName": "Jane", 245 | "lastName": "Doe", 246 | "email": "jane.doe@example.com", 247 | "password": "SecurePassword123!", 248 | "country": "USA", 249 | "age": 28 250 | } 251 | """ 252 | ) 253 | ] 254 | ) 255 | ] 256 | ) 257 | @RequestBody signUpRequest: SignUpRequest 258 | ): ResponseEntity { 259 | authRequestValidator.validateSignUpRequest(signUpRequest) 260 | 261 | if (userRepository.findByEmail(signUpRequest.email) != null) { 262 | throw EmailAlreadyExistsException("Email is already in use") 263 | } 264 | 265 | val user = UserDetails( 266 | firstName = signUpRequest.firstName, 267 | lastName = signUpRequest.lastName, 268 | email = signUpRequest.email, 269 | password = passwordEncoder.encode(signUpRequest.password), 270 | country = signUpRequest.country, 271 | age = signUpRequest.age.toInt(), 272 | createdAt = LocalDateTime.now(), 273 | updatedAt = LocalDateTime.now() 274 | ) 275 | 276 | userRepository.save(user) 277 | 278 | val userDetails = User(user.email, user.password, emptyList()) 279 | val accessToken = jwtTokenProvider.generateAccessToken(userDetails) 280 | val refreshToken = jwtTokenProvider.generateRefreshToken(userDetails) 281 | 282 | return ResponseEntity.ok(AuthResponse(accessToken, refreshToken)) 283 | } 284 | 285 | @Operation( 286 | summary = "Refresh Access Token", 287 | description = "Obtain a new access token using a valid refresh token." 288 | ) 289 | @ApiResponses( 290 | value = [ 291 | ApiResponse( 292 | responseCode = "200", 293 | description = "Successfully refreshed access token.", 294 | content = [ 295 | Content( 296 | mediaType = "application/json", 297 | schema = Schema(implementation = RefreshTokenResponse::class), 298 | examples = [ 299 | ExampleObject( 300 | name = "SuccessResponse", 301 | summary = "Successful Token Refresh", 302 | value = """ 303 | { 304 | "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." 305 | } 306 | """ 307 | ) 308 | ] 309 | ) 310 | ] 311 | ), 312 | ApiResponse( 313 | responseCode = "400", 314 | description = "Invalid request data.", 315 | content = [ 316 | Content( 317 | mediaType = "application/json", 318 | schema = Schema(implementation = ErrorResponse::class), 319 | examples = [ 320 | ExampleObject( 321 | name = "InvalidRequest", 322 | summary = "Missing or invalid refresh token", 323 | value = """ 324 | { 325 | "errorMessage": "Refresh token must not be empty." 326 | } 327 | """ 328 | ) 329 | ] 330 | ) 331 | ] 332 | ), 333 | ApiResponse( 334 | responseCode = "401", 335 | description = "Invalid or expired refresh token.", 336 | content = [ 337 | Content( 338 | mediaType = "application/json", 339 | schema = Schema(implementation = ErrorResponse::class), 340 | examples = [ 341 | ExampleObject( 342 | name = "InvalidToken", 343 | summary = "Refresh token invalid or expired", 344 | value = """ 345 | { 346 | "errorMessage": "Invalid or expired refresh token." 347 | } 348 | """ 349 | ) 350 | ] 351 | ) 352 | ] 353 | ), 354 | ApiResponse( 355 | responseCode = "404", 356 | description = "User not found.", 357 | content = [ 358 | Content( 359 | mediaType = "application/json", 360 | schema = Schema(implementation = ErrorResponse::class), 361 | examples = [ 362 | ExampleObject( 363 | name = "UserNotFound", 364 | summary = "No user associated with the refresh token", 365 | value = """ 366 | { 367 | "errorMessage": "User not found for the provided refresh token." 368 | } 369 | """ 370 | ) 371 | ] 372 | ) 373 | ] 374 | ) 375 | ] 376 | ) 377 | @PostMapping("/refresh-token") 378 | fun refreshAccessToken( 379 | @SwaggerRequestBody( 380 | description = "Refresh token request.", 381 | required = true, 382 | content = [ 383 | Content( 384 | mediaType = "application/json", 385 | schema = Schema(implementation = RefreshTokenRequest::class), 386 | examples = [ 387 | ExampleObject( 388 | name = "RefreshTokenExample", 389 | summary = "Example RefreshTokenRequest", 390 | value = """ 391 | { 392 | "refreshToken": "dGhpc0lzQVJlZnJlc2hUb2tlbi..." 393 | } 394 | """ 395 | ) 396 | ] 397 | ) 398 | ] 399 | ) 400 | @RequestBody refreshTokenRequest: RefreshTokenRequest 401 | ): ResponseEntity { 402 | authRequestValidator.validateRefreshTokenRequest(refreshTokenRequest) 403 | 404 | if (!jwtTokenProvider.validateToken(refreshTokenRequest.refreshToken)) { 405 | throw InvalidTokenException("Invalid or expired refresh token") 406 | } 407 | 408 | val username = jwtTokenProvider.getUsernameFromJWT(refreshTokenRequest.refreshToken) 409 | val userDetails = userRepository.findByEmail(username) 410 | ?: throw UserNotFoundException("User not found for the provided refresh token") 411 | 412 | val userSpringDetails = User( 413 | userDetails.email, 414 | userDetails.password, 415 | emptyList() 416 | ) 417 | val newAccessToken = jwtTokenProvider.generateAccessToken(userSpringDetails) 418 | 419 | return ResponseEntity.ok(RefreshTokenResponse(newAccessToken)) 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/dto/AuthResponse.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Authentication response containing access and refresh tokens.") 6 | data class AuthResponse( 7 | 8 | @Schema(description = "JWT access token.", required = true) 9 | val accessToken: String, 10 | 11 | @Schema(description = "JWT refresh token.", required = true) 12 | val refreshToken: String 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/dto/ErrorMessage.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.dto 2 | 3 | const val FIRST_NAME_MUST_NOT_BE_EMPTY = "First name must not be empty" 4 | const val FIRST_NAME_IS_TOO_LONG = "First name is too long" 5 | const val FIRST_NAME_CONTAINS_INVALID_CHARACTERS = "First name contains invalid characters" 6 | const val LAST_NAME_MUST_NOT_BE_EMPTY = "Last name must not be empty" 7 | const val LAST_NAME_IS_TOO_LONG = "Last name is too long" 8 | const val LAST_NAME_CONTAINS_INVALID_CHARACTERS = "Last name contains invalid characters" 9 | const val COUNTRY_MUST_NOT_BE_EMPTY = "Country name must not be empty" 10 | const val COUNTRY_NAME_IS_TOO_LONG = "Country name is too long" 11 | const val COUNTRY_NAME_CONTAINS_INVALID_CHARACTERS = "Country name contains invalid characters" 12 | const val AGE_MUST_NOT_BE_EMPTY = "Age must not be empty" 13 | const val AGE_MUST_BE_VALID_INT = "Age must be a valid integer" 14 | const val AGE_MUST_BE_BETWEEN_13_AND_120 = "Age must be between 13 and 120" 15 | 16 | const val EMAIL_MUST_NOT_BE_EMPTY = "Email must not be empty" 17 | const val EMAIL_FORMAT_IS_INVALID = "Email format is invalid" 18 | const val EMAIL_IS_TOO_LONG = "Email is too long" 19 | 20 | const val PASSWORD_MUST_NOT_BE_EMPTY = "Password must not be empty" 21 | const val PASSWORD_MUST_NOT_CONTAIN_SPACES = "Password must not contain spaces" 22 | const val PASSWORD_MUST_BE_AT_LEAST_8_CHARACTERS_LONG = "Password must be at least 8 characters long" 23 | const val PASSWORD_IS_TOO_LONG = "Password is too long" 24 | const val PASSWORD_MUST_CONTAIN_AT_LEAST_ONE_UPPERCASE_LETTER = "Password must contain at least one uppercase letter" 25 | const val PASSWORD_MUST_CONTAIN_AT_LEAST_ONE_LOWERCASE_LETTER = "Password must contain at least one lowercase letter" 26 | const val PASSWORD_MUST_CONTAIN_AT_LEAST_ONE_DIGIT = "Password must contain at least one digit" -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/dto/RefreshTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Request to refresh access token using refresh token.") 6 | data class RefreshTokenRequest( 7 | 8 | @Schema(description = "JWT refresh token.", required = true) 9 | val refreshToken: String 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/dto/RefreshTokenResponse.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Response containing a new access token.") 6 | data class RefreshTokenResponse( 7 | 8 | @Schema(description = "New JWT access token.", required = true) 9 | val accessToken: String 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/dto/SignInRequest.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Authentication request containing email and password.") 6 | data class SignInRequest( 7 | 8 | @Schema(description = "User's email address.", example = "user@example.com", required = true) 9 | val email: String = "", 10 | 11 | @Schema(description = "User's password.", example = "password123", required = true) 12 | val password: String = "" 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/dto/SignUpRequest.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Sign-up request containing new user details.") 6 | data class SignUpRequest( 7 | 8 | @Schema(description = "User's first name.", example = "John", required = true, maxLength = 50) 9 | val firstName: String = "", 10 | 11 | @Schema(description = "User's last name.", example = "Doe", required = true, maxLength = 50) 12 | val lastName: String = "", 13 | 14 | @Schema(description = "User's country.", example = "USA", required = true, maxLength = 50) 15 | val country: String = "", 16 | 17 | @Schema(description = "User's age.", example = "30", required = true, minimum = "1", maximum = "150") 18 | val age: String = "", 19 | 20 | @Schema(description = "User's email address.", example = "john.doe@example.com", required = true, maxLength = 100) 21 | val email: String = "", 22 | 23 | @Schema(description = "User's password.", example = "password123", required = true, minLength = 8, maxLength = 50) 24 | val password: String = "" 25 | ) 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/entity/UserDetails.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.entity 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.mongodb.core.mapping.Document 5 | import java.time.LocalDateTime 6 | 7 | @Document(collection = "user_details") 8 | data class UserDetails( 9 | 10 | @Id 11 | val id: String? = null, 12 | val firstName: String, 13 | val lastName: String, 14 | val password: String, 15 | val country: String, 16 | val age: Int, 17 | val email: String, 18 | val createdAt: LocalDateTime? = null, 19 | val updatedAt: LocalDateTime? = null, 20 | val lastLogin: LocalDateTime? = null 21 | ) 22 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/exception/EmailAlreadyExistsException.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.exception 2 | 3 | class EmailAlreadyExistsException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/exception/InvalidTokenException.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.exception 2 | 3 | class InvalidTokenException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/exception/UserNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.exception 2 | 3 | class UserNotFoundException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/repository/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.repository 2 | 3 | import com.zufar.urlshortener.auth.entity.UserDetails 4 | import org.springframework.data.mongodb.repository.MongoRepository 5 | 6 | interface UserRepository : MongoRepository { 7 | 8 | fun findByEmail(email: String): UserDetails? 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/service/CustomUserDetailsService.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.service 2 | 3 | import com.zufar.urlshortener.auth.repository.UserRepository 4 | import org.springframework.security.core.userdetails.UserDetailsService 5 | import org.springframework.security.core.userdetails.UserDetails 6 | import org.springframework.stereotype.Service 7 | import org.springframework.security.core.userdetails.UsernameNotFoundException 8 | 9 | @Service 10 | class CustomUserDetailsService(private val userRepository: UserRepository) : UserDetailsService { 11 | 12 | override fun loadUserByUsername(email: String): UserDetails { 13 | val user = userRepository.findByEmail(email) 14 | ?: throw UsernameNotFoundException("User with email='$email' is not found") 15 | 16 | return org.springframework.security.core.userdetails.User.builder() 17 | .username(user.email) 18 | .password(user.password) 19 | .authorities(emptyList()) 20 | .build() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/service/JwtAuthenticationFilter.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.service 2 | 3 | import jakarta.servlet.FilterChain 4 | import jakarta.servlet.ServletException 5 | import jakarta.servlet.http.HttpServletRequest 6 | import jakarta.servlet.http.HttpServletResponse 7 | import org.springframework.security.core.context.SecurityContextHolder 8 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 9 | import org.springframework.security.core.userdetails.UserDetails 10 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource 11 | import org.springframework.stereotype.Component 12 | import org.springframework.web.filter.OncePerRequestFilter 13 | import java.io.IOException 14 | 15 | @Component 16 | class JwtAuthenticationFilter( 17 | private val customUserDetailsService: CustomUserDetailsService, 18 | private val jwtTokenProvider: JwtTokenProvider 19 | ) : OncePerRequestFilter() { 20 | 21 | @Throws(ServletException::class, IOException::class) 22 | override fun doFilterInternal( 23 | request: HttpServletRequest, 24 | response: HttpServletResponse, 25 | filterChain: FilterChain 26 | ) { 27 | val jwt = getJwtFromRequest(request) 28 | 29 | if (jwt != null && jwtTokenProvider.validateToken(jwt)) { 30 | val username = jwtTokenProvider.getUsernameFromJWT(jwt) 31 | 32 | val userDetails: UserDetails = customUserDetailsService.loadUserByUsername(username) 33 | val authentication = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) 34 | authentication.details = WebAuthenticationDetailsSource().buildDetails(request) 35 | SecurityContextHolder.getContext().authentication = authentication 36 | } 37 | 38 | filterChain.doFilter(request, response) 39 | } 40 | 41 | private fun getJwtFromRequest(request: HttpServletRequest): String? { 42 | val bearerToken = request.getHeader("Authorization") 43 | if (bearerToken != null && bearerToken.startsWith("Bearer ")) { 44 | return bearerToken.substring(7) 45 | } 46 | return null 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/service/JwtTokenProvider.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.service 2 | 3 | import io.jsonwebtoken.* 4 | import io.jsonwebtoken.security.Keys 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.stereotype.Component 7 | import java.util.* 8 | import javax.crypto.SecretKey 9 | import org.springframework.security.core.userdetails.UserDetails 10 | 11 | @Component 12 | class JwtTokenProvider( 13 | @Value("\${jwt.secret}") private val jwtSecret: String, 14 | @Value("\${jwt.accessTokenExpiration}") private val jwtExpirationInMs: Long, 15 | @Value("\${jwt.refreshTokenExpiration}") private val jwtRefreshExpirationInMs: Long 16 | ) { 17 | private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtSecret.toByteArray()) 18 | 19 | fun generateAccessToken(userDetails: UserDetails): String { 20 | val now = Date() 21 | val expiryDate = Date(now.time + jwtExpirationInMs) 22 | return Jwts.builder() 23 | .setSubject(userDetails.username) 24 | .setIssuedAt(now) 25 | .setExpiration(expiryDate) 26 | .signWith(secretKey, SignatureAlgorithm.HS256) 27 | .compact() 28 | } 29 | 30 | fun generateRefreshToken(userDetails: UserDetails): String { 31 | val now = Date() 32 | val expiryDate = Date(now.time + jwtRefreshExpirationInMs) 33 | return Jwts.builder() 34 | .setSubject(userDetails.username) 35 | .setIssuedAt(now) 36 | .setExpiration(expiryDate) 37 | .signWith(secretKey, SignatureAlgorithm.HS256) 38 | .compact() 39 | } 40 | 41 | fun getUsernameFromJWT(token: String): String { 42 | val claims = Jwts.parserBuilder() 43 | .setSigningKey(secretKey) 44 | .build() 45 | .parseClaimsJws(token) 46 | .body 47 | return claims.subject 48 | } 49 | 50 | fun validateToken(authToken: String): Boolean { 51 | try { 52 | Jwts.parserBuilder() 53 | .setSigningKey(secretKey) 54 | .build() 55 | .parseClaimsJws(authToken) 56 | return true 57 | } catch (ex: Exception) { 58 | // Log the exception or handle it accordingly 59 | } 60 | return false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/service/validator/AuthRequestValidator.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.service.validator 2 | 3 | import com.zufar.urlshortener.auth.dto.SignInRequest 4 | import com.zufar.urlshortener.auth.dto.RefreshTokenRequest 5 | import com.zufar.urlshortener.auth.dto.SignUpRequest 6 | import com.zufar.urlshortener.common.exception.InvalidRequestException 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.stereotype.Service 9 | import com.zufar.urlshortener.auth.dto.* 10 | 11 | private const val MAX_COUNTRY_NAME_LENGTH = 50 12 | private const val MIN_JWT_TOKEN_LENGTH = 20 13 | private const val MAX_JWT_TOKEN_LENGTH = 500 14 | private const val MIN_AGE = 13 15 | private const val MAX_AGE = 120 16 | 17 | @Service 18 | class AuthRequestValidator( 19 | val emailOfUserValidator: EmailOfUserValidator, 20 | val passwordOfUserValidator: PasswordOfUserValidator 21 | ) { 22 | 23 | private val log = LoggerFactory.getLogger(AuthRequestValidator::class.java) 24 | 25 | fun validateAuthRequest(signInRequest: SignInRequest) { 26 | log.debug("Validating AuthRequest: {}", signInRequest) 27 | if (signInRequest.email.isBlank()) { 28 | throw InvalidRequestException(EMAIL_MUST_NOT_BE_EMPTY) 29 | } 30 | if (signInRequest.password.isBlank()) { 31 | throw InvalidRequestException(PASSWORD_MUST_NOT_BE_EMPTY) 32 | } 33 | } 34 | 35 | fun validateSignUpRequest(signUpRequest: SignUpRequest) { 36 | log.debug("Validating SignUpRequest: {}", signUpRequest) 37 | validateFirstName(signUpRequest.firstName) 38 | validateLastName(signUpRequest.lastName) 39 | validateCountry(signUpRequest.country) 40 | validateAge(signUpRequest.age) 41 | emailOfUserValidator.validate(signUpRequest.email) 42 | passwordOfUserValidator.validate(signUpRequest.password) 43 | } 44 | 45 | fun validateRefreshTokenRequest(refreshTokenRequest: RefreshTokenRequest) { 46 | log.debug("Validating RefreshTokenRequest: {}", refreshTokenRequest) 47 | val token = refreshTokenRequest.refreshToken 48 | if (token.isBlank()) { 49 | throw InvalidRequestException("Refresh token must not be empty") 50 | } 51 | if (token.length < MIN_JWT_TOKEN_LENGTH || token.length > MAX_JWT_TOKEN_LENGTH) { 52 | throw InvalidRequestException("Refresh token length is invalid") 53 | } 54 | } 55 | 56 | private fun validateFirstName(firstName: String) { 57 | if (firstName.isBlank()) { 58 | throw InvalidRequestException(FIRST_NAME_MUST_NOT_BE_EMPTY) 59 | } 60 | if (firstName.length > MAX_COUNTRY_NAME_LENGTH) { 61 | throw InvalidRequestException(FIRST_NAME_IS_TOO_LONG) 62 | } 63 | val nameRegex = Regex("^[a-zA-Z'-]+$") 64 | if (!firstName.matches(nameRegex)) { 65 | throw InvalidRequestException(FIRST_NAME_CONTAINS_INVALID_CHARACTERS) 66 | } 67 | } 68 | 69 | private fun validateLastName(lastName: String) { 70 | if (lastName.isBlank()) { 71 | throw InvalidRequestException(LAST_NAME_MUST_NOT_BE_EMPTY) 72 | } 73 | if (lastName.length > MAX_COUNTRY_NAME_LENGTH) { 74 | throw InvalidRequestException(LAST_NAME_IS_TOO_LONG) 75 | } 76 | val nameRegex = Regex("^[a-zA-Z'-]+$") 77 | if (!lastName.matches(nameRegex)) { 78 | throw InvalidRequestException(LAST_NAME_CONTAINS_INVALID_CHARACTERS) 79 | } 80 | } 81 | 82 | private fun validateCountry(country: String) { 83 | if (country.isBlank()) { 84 | throw InvalidRequestException(COUNTRY_MUST_NOT_BE_EMPTY) 85 | } 86 | if (country.length > MAX_COUNTRY_NAME_LENGTH) { 87 | throw InvalidRequestException(COUNTRY_NAME_IS_TOO_LONG) 88 | } 89 | val nameRegex = Regex("^[a-zA-Z'\\-]+(\\s[a-zA-Z'\\-]+)*$") 90 | if (!country.matches(nameRegex)) { 91 | throw InvalidRequestException(COUNTRY_NAME_CONTAINS_INVALID_CHARACTERS) 92 | } 93 | } 94 | 95 | private fun validateAge(age: String) { 96 | if (age.isBlank()) { 97 | throw InvalidRequestException(AGE_MUST_NOT_BE_EMPTY) 98 | } 99 | 100 | val ageInt = age.toIntOrNull() ?: throw InvalidRequestException(AGE_MUST_BE_VALID_INT) 101 | 102 | if (ageInt < MIN_AGE || ageInt > MAX_AGE) { 103 | throw InvalidRequestException(AGE_MUST_BE_BETWEEN_13_AND_120) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/service/validator/EmailOfUserValidator.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.service.validator 2 | 3 | import com.zufar.urlshortener.auth.dto.* 4 | import com.zufar.urlshortener.common.exception.InvalidRequestException 5 | import org.apache.commons.validator.routines.EmailValidator 6 | import org.springframework.stereotype.Service 7 | 8 | private const val MAX_EMAIL_LENGTH = 64 9 | 10 | @Service 11 | class EmailOfUserValidator { 12 | 13 | fun validate(email: String) { 14 | if (email.isBlank()) { 15 | throw InvalidRequestException(EMAIL_MUST_NOT_BE_EMPTY) 16 | } 17 | 18 | if (email.length > MAX_EMAIL_LENGTH) { 19 | throw InvalidRequestException(EMAIL_IS_TOO_LONG) 20 | } 21 | 22 | if (!EmailValidator.getInstance().isValid(email)) { 23 | throw InvalidRequestException(EMAIL_FORMAT_IS_INVALID) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/auth/service/validator/PasswordOfUserValidator.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.auth.service.validator 2 | 3 | import com.zufar.urlshortener.common.exception.InvalidRequestException 4 | import org.springframework.stereotype.Service 5 | import com.zufar.urlshortener.auth.dto.* 6 | 7 | private const val MIN_PASSWORD_LENGTH = 8 8 | private const val MAX_PASSWORD_LENGTH = 50 9 | @Service 10 | class PasswordOfUserValidator { 11 | 12 | fun validate(password: String) { 13 | if (password.isBlank()) { 14 | throw InvalidRequestException(PASSWORD_MUST_NOT_BE_EMPTY) 15 | } 16 | if (password.contains(" ")) { 17 | throw InvalidRequestException(PASSWORD_MUST_NOT_CONTAIN_SPACES) 18 | } 19 | if (password.length < MIN_PASSWORD_LENGTH) { 20 | throw InvalidRequestException(PASSWORD_MUST_BE_AT_LEAST_8_CHARACTERS_LONG) 21 | } 22 | if (password.length > MAX_PASSWORD_LENGTH) { 23 | throw InvalidRequestException(PASSWORD_IS_TOO_LONG) 24 | } 25 | 26 | val uppercaseRegex = Regex("[A-Z]") 27 | val lowercaseRegex = Regex("[a-z]") 28 | val digitRegex = Regex("[0-9]") 29 | 30 | if (!password.contains(uppercaseRegex)) { 31 | throw InvalidRequestException(PASSWORD_MUST_CONTAIN_AT_LEAST_ONE_UPPERCASE_LETTER) 32 | } 33 | if (!password.contains(lowercaseRegex)) { 34 | throw InvalidRequestException(PASSWORD_MUST_CONTAIN_AT_LEAST_ONE_LOWERCASE_LETTER) 35 | } 36 | if (!password.contains(digitRegex)) { 37 | throw InvalidRequestException(PASSWORD_MUST_CONTAIN_AT_LEAST_ONE_DIGIT) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/common/config/OpenAPIConfig.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.common.config 2 | 3 | import org.springdoc.core.models.GroupedOpenApi 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | 7 | @Configuration 8 | class OpenAPIConfig { 9 | 10 | @Bean 11 | fun publicApi(): GroupedOpenApi { 12 | return GroupedOpenApi.builder() 13 | .group("public") 14 | .pathsToMatch("/**") 15 | .build() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/common/config/SecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.common.config 2 | 3 | import com.zufar.urlshortener.auth.service.CustomUserDetailsService 4 | import com.zufar.urlshortener.auth.service.JwtAuthenticationFilter 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.authentication.configuration.AuthenticationConfiguration 9 | import org.springframework.security.authentication.AuthenticationManager 10 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 11 | import org.springframework.security.web.SecurityFilterChain 12 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity 13 | import org.springframework.security.authentication.dao.DaoAuthenticationProvider 14 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 15 | import org.springframework.security.crypto.password.PasswordEncoder 16 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 17 | 18 | @Configuration 19 | @EnableMethodSecurity 20 | class SecurityConfig( 21 | private val customUserDetailsService: CustomUserDetailsService, 22 | private val jwtAuthenticationFilter: JwtAuthenticationFilter 23 | ) { 24 | 25 | @Bean 26 | fun authenticationProvider(): DaoAuthenticationProvider { 27 | val authProvider = DaoAuthenticationProvider() 28 | authProvider.setUserDetailsService(customUserDetailsService) 29 | authProvider.setPasswordEncoder(passwordEncoder()) 30 | return authProvider 31 | } 32 | 33 | @Bean 34 | fun passwordEncoder(): PasswordEncoder { 35 | return BCryptPasswordEncoder() 36 | } 37 | 38 | @Bean 39 | @Throws(Exception::class) 40 | fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager { 41 | return authenticationConfiguration.authenticationManager 42 | } 43 | 44 | @Bean 45 | @Throws(Exception::class) 46 | fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { 47 | http.csrf { csrf -> csrf.disable() } 48 | .authorizeHttpRequests { auth -> 49 | auth 50 | .requestMatchers(HttpMethod.POST, "/api/v1/urls").permitAll() 51 | .requestMatchers("/api/v1/auth/**", "/url/**", "/api/v1/swagger-ui/**", "/api/v1/api-docs/**").permitAll() 52 | .anyRequest().authenticated() 53 | } 54 | .authenticationProvider(authenticationProvider()) 55 | .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) 56 | return http.build() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/common/exception/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.common.exception 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Represents an error response with an errorMessage") 6 | data class ErrorResponse( 7 | 8 | @Schema(description = "Detailed error message") 9 | val errorMessage: String 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/common/exception/GlobalExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.common.exception 2 | 3 | import com.zufar.urlshortener.auth.exception.EmailAlreadyExistsException 4 | import com.zufar.urlshortener.auth.exception.InvalidTokenException 5 | import com.zufar.urlshortener.shorten.exception.UrlNotFoundException 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.security.authentication.BadCredentialsException 10 | import org.springframework.web.bind.annotation.ControllerAdvice 11 | import org.springframework.web.bind.annotation.ExceptionHandler 12 | import javax.naming.AuthenticationException 13 | 14 | private const val LOG_ERROR_MESSAGE = "An unexpected error occurred" 15 | 16 | @ControllerAdvice 17 | class GlobalExceptionHandler { 18 | 19 | private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) 20 | 21 | @ExceptionHandler(InvalidRequestException::class) 22 | fun handleInvalidRequestException(ex: InvalidRequestException): ResponseEntity { 23 | log.error("Invalid request: ${ex.message}", ex) 24 | val errorResponse = ErrorResponse(errorMessage = ex.message ?: "Invalid request") 25 | return ResponseEntity(errorResponse, HttpStatus.BAD_REQUEST) 26 | } 27 | 28 | @ExceptionHandler(IllegalArgumentException::class) 29 | fun handleIllegalArgumentException(ex: IllegalArgumentException): ResponseEntity { 30 | log.error(LOG_ERROR_MESSAGE, ex) 31 | val errorResponse = ErrorResponse(errorMessage = ex.message ?: "Invalid input") 32 | return ResponseEntity(errorResponse, HttpStatus.BAD_REQUEST) 33 | } 34 | 35 | @ExceptionHandler(Exception::class) 36 | fun handleException(ex: Exception): ResponseEntity { 37 | log.error(LOG_ERROR_MESSAGE, ex) 38 | val errorMessage = "An unexpected error occurred: ${ex.message ?: "No additional details provided"}" 39 | val errorResponse = ErrorResponse(errorMessage = errorMessage) 40 | return ResponseEntity(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR) 41 | } 42 | 43 | @ExceptionHandler(UrlNotFoundException::class) 44 | fun handleUrlNotFound(ex: UrlNotFoundException): ResponseEntity { 45 | log.error(LOG_ERROR_MESSAGE, ex) 46 | val errorResponse = ErrorResponse(errorMessage = ex.message ?: "URL not found") 47 | return ResponseEntity(errorResponse, HttpStatus.NOT_FOUND) 48 | } 49 | 50 | @ExceptionHandler(InvalidTokenException::class) 51 | fun handleInvalidTokenException(ex: InvalidTokenException): ResponseEntity { 52 | log.error("Invalid token: ${ex.message}", ex) 53 | val errorResponse = ErrorResponse(errorMessage = ex.message ?: "Invalid token") 54 | return ResponseEntity(errorResponse, HttpStatus.UNAUTHORIZED) 55 | } 56 | 57 | @ExceptionHandler(org.springframework.security.authentication.BadCredentialsException::class) 58 | fun handleBadCredentialsException(ex: BadCredentialsException): ResponseEntity { 59 | val errorResponse = ErrorResponse(errorMessage = "Invalid email or password") 60 | return ResponseEntity(errorResponse, HttpStatus.UNAUTHORIZED) 61 | } 62 | 63 | @ExceptionHandler(AuthenticationException::class) 64 | fun handleAuthenticationException(ex: AuthenticationException): ResponseEntity { 65 | val errorResponse = ErrorResponse(errorMessage = "Authentication failed") 66 | return ResponseEntity(errorResponse, HttpStatus.UNAUTHORIZED) 67 | } 68 | 69 | @ExceptionHandler(EmailAlreadyExistsException::class) 70 | fun handleEmailAlreadyExistsException(ex: EmailAlreadyExistsException): ResponseEntity { 71 | val errorResponse = ErrorResponse(errorMessage = ex.message ?: "Email already in use") 72 | return ResponseEntity(errorResponse, HttpStatus.CONFLICT) 73 | } 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/common/exception/InvalidRequestException.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.common.exception 2 | 3 | class InvalidRequestException(message: String) : RuntimeException(message) -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/controller/UrlController.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.controller 2 | 3 | import com.zufar.urlshortener.common.exception.ErrorResponse 4 | import com.zufar.urlshortener.shorten.dto.* 5 | import com.zufar.urlshortener.shorten.repository.UrlRepository 6 | import com.zufar.urlshortener.shorten.service.* 7 | import io.swagger.v3.oas.annotations.Operation 8 | import io.swagger.v3.oas.annotations.Parameter 9 | import io.swagger.v3.oas.annotations.media.* 10 | import io.swagger.v3.oas.annotations.parameters.RequestBody as SwaggerRequestBody 11 | import io.swagger.v3.oas.annotations.responses.ApiResponse 12 | import io.swagger.v3.oas.annotations.responses.ApiResponses 13 | import io.swagger.v3.oas.annotations.tags.Tag 14 | import jakarta.servlet.http.HttpServletRequest 15 | import org.slf4j.LoggerFactory 16 | import org.springframework.http.MediaType 17 | import org.springframework.http.ResponseEntity 18 | import org.springframework.web.bind.annotation.* 19 | 20 | @RestController 21 | @RequestMapping("/api/v1/urls") 22 | @Tag( 23 | name = "URL Management", 24 | description = "Operations for managing shortened URLs, including creating, retrieving, and deleting URL mappings." 25 | ) 26 | class UrlController( 27 | private val urlShortener: UrlShortener, 28 | private val urlRepository: UrlRepository, 29 | private val urlDeleter: UrlDeleter, 30 | private val pageableUrlMappingsProvider: PageableUrlMappingsProvider, 31 | private val urlMappingProvider: UrlMappingProvider 32 | ) { 33 | private val log = LoggerFactory.getLogger(UrlController::class.java) 34 | 35 | @Operation( 36 | summary = "Shorten a URL", 37 | description = "Generates a shortened URL from a given long URL. Returns a shorter unique URL that redirects to the original URL.", 38 | tags = ["URL Shortening"] 39 | ) 40 | @ApiResponses( 41 | value = [ 42 | ApiResponse( 43 | responseCode = "200", 44 | description = "URL shortened successfully.", 45 | content = [ 46 | Content( 47 | mediaType = MediaType.APPLICATION_JSON_VALUE, 48 | schema = Schema(implementation = UrlResponse::class), 49 | examples = [ 50 | ExampleObject( 51 | name = "ShortenUrlSuccess", 52 | summary = "Successful Response", 53 | value = """ 54 | { 55 | "shortUrl": "https://short.ly/abc123" 56 | } 57 | """ 58 | ) 59 | ] 60 | ) 61 | ] 62 | ), 63 | ApiResponse( 64 | responseCode = "400", 65 | description = "Invalid URL provided.", 66 | content = [ 67 | Content( 68 | mediaType = MediaType.APPLICATION_JSON_VALUE, 69 | schema = Schema(implementation = ErrorResponse::class), 70 | examples = [ 71 | ExampleObject( 72 | name = "InvalidUrlError", 73 | summary = "The URL is not valid", 74 | value = """ 75 | { 76 | "errorMessage": "URL must not contain spaces." 77 | } 78 | """ 79 | ) 80 | ] 81 | ) 82 | ] 83 | ), 84 | ApiResponse( 85 | responseCode = "401", 86 | description = "Unauthorized access.", 87 | content = [ 88 | Content( 89 | mediaType = MediaType.APPLICATION_JSON_VALUE, 90 | schema = Schema(implementation = ErrorResponse::class), 91 | examples = [ 92 | ExampleObject( 93 | name = "UnauthorizedError", 94 | summary = "Authentication is required", 95 | value = """ 96 | { 97 | "errorMessage": "Unauthorized access." 98 | } 99 | """ 100 | ) 101 | ] 102 | ) 103 | ] 104 | ), 105 | ApiResponse( 106 | responseCode = "500", 107 | description = "Unexpected server error.", 108 | content = [ 109 | Content( 110 | mediaType = MediaType.APPLICATION_JSON_VALUE, 111 | schema = Schema(implementation = ErrorResponse::class), 112 | examples = [ 113 | ExampleObject( 114 | name = "ServerError", 115 | summary = "Internal server error", 116 | value = """ 117 | { 118 | "errorMessage": "An unexpected error occurred." 119 | } 120 | """ 121 | ) 122 | ] 123 | ) 124 | ] 125 | ) 126 | ] 127 | ) 128 | @PostMapping( 129 | consumes = [MediaType.APPLICATION_JSON_VALUE], 130 | produces = [MediaType.APPLICATION_JSON_VALUE] 131 | ) 132 | fun shortenUrl( 133 | @SwaggerRequestBody( 134 | description = "Payload containing the original URL to be shortened.", 135 | required = true, 136 | content = [ 137 | Content( 138 | mediaType = MediaType.APPLICATION_JSON_VALUE, 139 | schema = Schema(implementation = ShortenUrlRequest::class), 140 | examples = [ 141 | ExampleObject( 142 | name = "ShortenUrlExample", 143 | summary = "Example URL to shorten", 144 | value = """ 145 | { 146 | "originalUrl": "https://www.example.com/some/long/url", 147 | "daysCount": 30 148 | } 149 | """ 150 | ) 151 | ] 152 | ) 153 | ] 154 | ) 155 | @RequestBody shortenUrlRequest: ShortenUrlRequest, 156 | httpServletRequest: HttpServletRequest 157 | ): ResponseEntity { 158 | val originalUrl = shortenUrlRequest.originalUrl 159 | 160 | log.info( 161 | "Received request to shorten the originalUrl='{}' from IP='{}', User-Agent='{}'", 162 | originalUrl, 163 | httpServletRequest.remoteAddr, 164 | httpServletRequest.getHeader("User-Agent") 165 | ) 166 | 167 | val urlHash = StringEncoder.encode(originalUrl) 168 | val urlMapping = urlRepository.findByUrlHash(urlHash) 169 | val shortUrl: String 170 | 171 | if (urlMapping.isEmpty) { 172 | log.info("No existing shortUrl found for the urlHash='{}'. Creating a new one.", urlHash) 173 | shortUrl = urlShortener.shortenUrl(shortenUrlRequest, httpServletRequest) 174 | } else { 175 | shortUrl = urlMapping.get().shortUrl 176 | } 177 | 178 | return ResponseEntity.ok(UrlResponse(shortUrl)) 179 | } 180 | 181 | @Operation( 182 | summary = "Delete a shortened URL", 183 | description = "Deletes a URL mapping by its unique hash. The URL mapping can only be deleted by its creator.", 184 | tags = ["URL Management"] 185 | ) 186 | @ApiResponses( 187 | value = [ 188 | ApiResponse( 189 | responseCode = "204", 190 | description = "URL mapping deleted successfully." 191 | ), 192 | ApiResponse( 193 | responseCode = "401", 194 | description = "Unauthorized access.", 195 | content = [ 196 | Content( 197 | mediaType = MediaType.APPLICATION_JSON_VALUE, 198 | schema = Schema(implementation = ErrorResponse::class), 199 | examples = [ 200 | ExampleObject( 201 | name = "UnauthorizedError", 202 | summary = "Authentication required", 203 | value = """ 204 | { 205 | "errorMessage": "Unauthorized access." 206 | } 207 | """ 208 | ) 209 | ] 210 | ) 211 | ] 212 | ), 213 | ApiResponse( 214 | responseCode = "404", 215 | description = "URL mapping not found.", 216 | content = [ 217 | Content( 218 | mediaType = MediaType.APPLICATION_JSON_VALUE, 219 | schema = Schema(implementation = ErrorResponse::class), 220 | examples = [ 221 | ExampleObject( 222 | name = "NotFoundError", 223 | summary = "No URL mapping found for the given hash", 224 | value = """ 225 | { 226 | "errorMessage": "URL mapping not found." 227 | } 228 | """ 229 | ) 230 | ] 231 | ) 232 | ] 233 | ), 234 | ApiResponse( 235 | responseCode = "500", 236 | description = "Unexpected server error.", 237 | content = [ 238 | Content( 239 | mediaType = MediaType.APPLICATION_JSON_VALUE, 240 | schema = Schema(implementation = ErrorResponse::class), 241 | examples = [ 242 | ExampleObject( 243 | name = "ServerError", 244 | summary = "Internal server error", 245 | value = """ 246 | { 247 | "errorMessage": "An unexpected error occurred." 248 | } 249 | """ 250 | ) 251 | ] 252 | ) 253 | ] 254 | ) 255 | ] 256 | ) 257 | @DeleteMapping("/{urlHash}") 258 | fun deleteUrlMapping( 259 | @Parameter( 260 | description = "The unique hash identifier of the URL mapping to be deleted.", 261 | example = "abc123", 262 | required = true 263 | ) 264 | @PathVariable urlHash: String 265 | ): ResponseEntity { 266 | log.info("Received request to delete URL mapping for urlHash='{}'", urlHash) 267 | urlDeleter.deleteUrl(urlHash) 268 | log.info("Successfully deleted URL mapping for urlHash='{}'", urlHash) 269 | return ResponseEntity.noContent().build() 270 | } 271 | 272 | @Operation( 273 | summary = "Get user's URL mappings", 274 | description = "Retrieve a paginated list of URL mappings created by the authenticated user.", 275 | tags = ["URL Management"] 276 | ) 277 | @ApiResponses( 278 | value = [ 279 | ApiResponse( 280 | responseCode = "200", 281 | description = "Successfully retrieved URL mappings.", 282 | content = [ 283 | Content( 284 | mediaType = MediaType.APPLICATION_JSON_VALUE, 285 | schema = Schema(implementation = UrlMappingPageDto::class), 286 | examples = [ 287 | ExampleObject( 288 | name = "UrlMappingsPageExample", 289 | summary = "Example of URL mappings page", 290 | value = """ 291 | { 292 | "content": [ 293 | { 294 | "urlHash": "abc123", 295 | "shortUrl": "https://short.ly/abc123", 296 | "originalUrl": "https://www.example.com/very/long/url1", 297 | "createdAt": "2023-10-17T12:34:56", 298 | "expirationDate": "2024-10-17T12:34:56" 299 | }, 300 | { 301 | "urlHash": "def456", 302 | "shortUrl": "https://short.ly/def456", 303 | "originalUrl": "https://www.example.com/very/long/url2", 304 | "createdAt": "2023-10-18T12:34:56", 305 | "expirationDate": "2024-10-18T12:34:56" 306 | } 307 | ], 308 | "page": 0, 309 | "size": 10, 310 | "totalElements": 2, 311 | "totalPages": 1 312 | } 313 | """ 314 | ) 315 | ] 316 | ) 317 | ] 318 | ), 319 | ApiResponse( 320 | responseCode = "401", 321 | description = "Unauthorized access.", 322 | content = [ 323 | Content( 324 | mediaType = MediaType.APPLICATION_JSON_VALUE, 325 | schema = Schema(implementation = ErrorResponse::class), 326 | examples = [ 327 | ExampleObject( 328 | name = "UnauthorizedError", 329 | summary = "Authentication required", 330 | value = """ 331 | { 332 | "errorMessage": "Unauthorized access." 333 | } 334 | """ 335 | ) 336 | ] 337 | ) 338 | ] 339 | ), 340 | ApiResponse( 341 | responseCode = "500", 342 | description = "Unexpected server error.", 343 | content = [ 344 | Content( 345 | mediaType = MediaType.APPLICATION_JSON_VALUE, 346 | schema = Schema(implementation = ErrorResponse::class), 347 | examples = [ 348 | ExampleObject( 349 | name = "ServerError", 350 | summary = "Internal server error", 351 | value = """ 352 | { 353 | "errorMessage": "An unexpected error occurred." 354 | } 355 | """ 356 | ) 357 | ] 358 | ) 359 | ] 360 | ) 361 | ] 362 | ) 363 | @GetMapping 364 | fun getUserUrlMappings( 365 | @Parameter( 366 | description = "Page number (zero-based).", 367 | example = "0", 368 | required = false 369 | ) 370 | @RequestParam(defaultValue = "0") page: Int, 371 | @Parameter( 372 | description = "Page size.", 373 | example = "10", 374 | required = false 375 | ) 376 | @RequestParam(defaultValue = "10") size: Int 377 | ): ResponseEntity { 378 | val urlMappingsPage = pageableUrlMappingsProvider.getUrlMappingsPage(page, size) 379 | return ResponseEntity.ok(urlMappingsPage) 380 | } 381 | 382 | @Operation( 383 | summary = "Get URL mapping by URL hash", 384 | description = "Retrieve a URL mapping by its unique hash.", 385 | tags = ["URL Management"] 386 | ) 387 | @ApiResponses( 388 | value = [ 389 | ApiResponse( 390 | responseCode = "200", 391 | description = "Successfully retrieved URL mapping.", 392 | content = [ 393 | Content( 394 | mediaType = MediaType.APPLICATION_JSON_VALUE, 395 | schema = Schema(implementation = UrlMappingDto::class), 396 | examples = [ 397 | ExampleObject( 398 | name = "UrlMappingExample", 399 | summary = "Example of a URL mapping", 400 | value = """ 401 | { 402 | "urlHash": "abc123", 403 | "shortUrl": "https://short.ly/abc123", 404 | "originalUrl": "https://www.example.com/very/long/url", 405 | "createdAt": "2023-10-17T12:34:56", 406 | "expirationDate": "2024-10-17T12:34:56" 407 | } 408 | """ 409 | ) 410 | ] 411 | ) 412 | ] 413 | ), 414 | ApiResponse( 415 | responseCode = "400", 416 | description = "Invalid URL hash format.", 417 | content = [ 418 | Content( 419 | mediaType = MediaType.APPLICATION_JSON_VALUE, 420 | schema = Schema(implementation = ErrorResponse::class), 421 | examples = [ 422 | ExampleObject( 423 | name = "InvalidUrlHashError", 424 | summary = "Invalid URL hash provided", 425 | value = """ 426 | { 427 | "errorMessage": "Invalid URL hash format." 428 | } 429 | """ 430 | ) 431 | ] 432 | ) 433 | ] 434 | ), 435 | ApiResponse( 436 | responseCode = "401", 437 | description = "Unauthorized access.", 438 | content = [ 439 | Content( 440 | mediaType = MediaType.APPLICATION_JSON_VALUE, 441 | schema = Schema(implementation = ErrorResponse::class), 442 | examples = [ 443 | ExampleObject( 444 | name = "UnauthorizedError", 445 | summary = "Authentication required", 446 | value = """ 447 | { 448 | "errorMessage": "Unauthorized access." 449 | } 450 | """ 451 | ) 452 | ] 453 | ) 454 | ] 455 | ), 456 | ApiResponse( 457 | responseCode = "404", 458 | description = "URL mapping not found.", 459 | content = [ 460 | Content( 461 | mediaType = MediaType.APPLICATION_JSON_VALUE, 462 | schema = Schema(implementation = ErrorResponse::class), 463 | examples = [ 464 | ExampleObject( 465 | name = "NotFoundError", 466 | summary = "URL mapping not found", 467 | value = """ 468 | { 469 | "errorMessage": "URL mapping not found." 470 | } 471 | """ 472 | ) 473 | ] 474 | ) 475 | ] 476 | ), 477 | ApiResponse( 478 | responseCode = "500", 479 | description = "Unexpected server error.", 480 | content = [ 481 | Content( 482 | mediaType = MediaType.APPLICATION_JSON_VALUE, 483 | schema = Schema(implementation = ErrorResponse::class), 484 | examples = [ 485 | ExampleObject( 486 | name = "ServerError", 487 | summary = "Internal server error", 488 | value = """ 489 | { 490 | "errorMessage": "An unexpected error occurred." 491 | } 492 | """ 493 | ) 494 | ] 495 | ) 496 | ] 497 | ) 498 | ] 499 | ) 500 | @GetMapping("/{urlHash}") 501 | fun getUrlMappingByHash( 502 | @Parameter( 503 | description = "The unique hash of the URL mapping.", 504 | example = "abc123", 505 | required = true 506 | ) 507 | @PathVariable urlHash: String 508 | ): ResponseEntity { 509 | val urlMapping = urlMappingProvider.getUrlMappingByHash(urlHash) 510 | return ResponseEntity.ok(urlMapping) 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/controller/UrlRedirectController.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.controller 2 | 3 | import com.zufar.urlshortener.common.exception.ErrorResponse 4 | import com.zufar.urlshortener.shorten.exception.UrlNotFoundException 5 | import com.zufar.urlshortener.shorten.repository.UrlRepository 6 | import io.swagger.v3.oas.annotations.Operation 7 | import io.swagger.v3.oas.annotations.Parameter 8 | import io.swagger.v3.oas.annotations.headers.Header 9 | import io.swagger.v3.oas.annotations.media.Content 10 | import io.swagger.v3.oas.annotations.media.ExampleObject 11 | import io.swagger.v3.oas.annotations.media.Schema 12 | import io.swagger.v3.oas.annotations.responses.ApiResponse 13 | import io.swagger.v3.oas.annotations.tags.Tag 14 | import jakarta.servlet.http.HttpServletRequest 15 | import org.slf4j.LoggerFactory 16 | import org.springframework.beans.factory.annotation.Value 17 | import org.springframework.http.HttpStatus 18 | import org.springframework.http.ResponseEntity 19 | import org.springframework.web.bind.annotation.* 20 | import java.net.URI 21 | 22 | @RestController 23 | @RequestMapping 24 | @Tag( 25 | name = "URL Redirection", 26 | description = "Operations related to redirecting shortened URLs to their original destinations." 27 | ) 28 | class UrlRedirectController(private val urlRepository: UrlRepository) { 29 | 30 | private val log = LoggerFactory.getLogger(UrlRedirectController::class.java) 31 | 32 | @Value("\${app.base-url}") 33 | private lateinit var baseUrl: String 34 | 35 | @Operation( 36 | summary = "Redirect to the Original URL", 37 | description = "Redirects the user to the original URL based on the shortened URL identifier." 38 | ) 39 | @ApiResponse( 40 | responseCode = "302", 41 | description = "Redirection to the original URL successful.", 42 | headers = [ 43 | Header( 44 | name = "Location", 45 | description = "The URL to which the client is redirected.", 46 | schema = Schema(type = "string", format = "uri", example = "https://www.example.com/original-page") 47 | ) 48 | ] 49 | ) 50 | @ApiResponse( 51 | responseCode = "404", 52 | description = "Shortened URL not found.", 53 | content = [ 54 | Content( 55 | mediaType = "application/json", 56 | schema = Schema(implementation = ErrorResponse::class), 57 | examples = [ 58 | ExampleObject( 59 | name = "Short URL Not Found", 60 | summary = "The shortened URL does not exist.", 61 | value = """ 62 | { 63 | "errorCode": "URL_NOT_FOUND", 64 | "errorMessage": "Original URL is absent for urlHash='abcd1234'" 65 | } 66 | """ 67 | ) 68 | ] 69 | ) 70 | ] 71 | ) 72 | @ApiResponse( 73 | responseCode = "500", 74 | description = "Internal server error.", 75 | content = [ 76 | Content( 77 | mediaType = "application/json", 78 | schema = Schema(implementation = ErrorResponse::class), 79 | examples = [ 80 | ExampleObject( 81 | name = "ServerError", 82 | summary = "An unexpected error occurred.", 83 | value = """ 84 | { 85 | "errorCode": "INTERNAL_SERVER_ERROR", 86 | "errorMessage": "An unexpected error occurred." 87 | } 88 | """ 89 | ) 90 | ] 91 | ) 92 | ] 93 | ) 94 | @GetMapping( 95 | "/url/{urlHash}", 96 | produces = ["application/json"] 97 | ) 98 | fun redirect( 99 | @Parameter( 100 | description = "The unique identifier (hash) of the shortened URL.", 101 | required = true, 102 | example = "abcd1234", 103 | schema = Schema(type = "string", maxLength = 15) 104 | ) 105 | @PathVariable urlHash: String, 106 | httpServletRequest: HttpServletRequest 107 | ): ResponseEntity { 108 | val clientIp = httpServletRequest.remoteAddr 109 | val userAgent = httpServletRequest.getHeader("User-Agent") 110 | 111 | log.info( 112 | "Received redirect request for shortUrl='{}/{}' from IP='{}', User-Agent='{}'", 113 | baseUrl, 114 | urlHash, 115 | clientIp, 116 | userAgent 117 | ) 118 | 119 | val urlMapping = urlRepository.findByUrlHash(urlHash) 120 | 121 | if (urlMapping.isEmpty) { 122 | log.error("Original URL not found for urlHash='{}'", urlHash) 123 | throw UrlNotFoundException("Original URL is absent for urlHash='$urlHash'") 124 | } 125 | 126 | log.info("Redirecting to the originalUrl='{}'", urlMapping.get().originalUrl) 127 | return ResponseEntity.status(HttpStatus.FOUND) 128 | .location(URI(urlMapping.get().originalUrl)) 129 | .build() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/controller/UserProviderController.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.controller 2 | 3 | import com.zufar.urlshortener.common.exception.ErrorResponse 4 | import com.zufar.urlshortener.shorten.dto.UserDetailsDto 5 | import com.zufar.urlshortener.shorten.service.UserDetailsProvider 6 | import io.swagger.v3.oas.annotations.Operation 7 | import io.swagger.v3.oas.annotations.media.Content 8 | import io.swagger.v3.oas.annotations.media.ExampleObject 9 | import io.swagger.v3.oas.annotations.media.Schema 10 | import io.swagger.v3.oas.annotations.responses.ApiResponse 11 | import io.swagger.v3.oas.annotations.tags.Tag 12 | import org.springframework.http.MediaType 13 | import org.springframework.http.ResponseEntity 14 | import org.springframework.web.bind.annotation.GetMapping 15 | import org.springframework.web.bind.annotation.RequestMapping 16 | import org.springframework.web.bind.annotation.RestController 17 | 18 | @RestController 19 | @RequestMapping("/api/v1/users") 20 | @Tag( 21 | name = "User Management", 22 | description = "Operations related to managing and retrieving user details." 23 | ) 24 | class UserProviderController(private val userDetailsProvider: UserDetailsProvider) { 25 | 26 | @Operation( 27 | summary = "Retrieve User Details", 28 | description = "Fetches details of the authenticated user. The request must be authenticated, and the user must exist in the system.", 29 | tags = ["User Management"] 30 | ) 31 | @ApiResponse( 32 | responseCode = "200", 33 | description = "Successfully retrieved the user details.", 34 | content = [Content( 35 | mediaType = "application/json", 36 | schema = Schema(implementation = UserDetailsDto::class), 37 | examples = [ExampleObject( 38 | name = "User Details Example", 39 | summary = "Example of a successful response", 40 | value = """{ 41 | "firstName": "John", 42 | "lastName": "Doe", 43 | "email": "john.doe@example.com", 44 | "country": "USA", 45 | "age": 30, 46 | "createdAt": "2024-01-15T10:00:00Z", 47 | "updatedAt": "2024-06-20T08:30:00Z" 48 | }""" 49 | )] 50 | )] 51 | ) 52 | @ApiResponse( 53 | responseCode = "401", 54 | description = "Unauthorized access. The request lacks valid authentication credentials.", 55 | content = [Content( 56 | mediaType = "application/json", 57 | schema = Schema(implementation = ErrorResponse::class), 58 | examples = [ExampleObject( 59 | name = "Unauthorized Example", 60 | summary = "Example of an unauthorized response", 61 | value = """{ 62 | "errorCode": "UNAUTHORIZED", 63 | "errorMessage": "Authentication credentials are required." 64 | }""" 65 | )] 66 | )] 67 | ) 68 | @ApiResponse( 69 | responseCode = "404", 70 | description = "User not found. The requested user does not exist in the system.", 71 | content = [Content( 72 | mediaType = "application/json", 73 | schema = Schema(implementation = ErrorResponse::class), 74 | examples = [ExampleObject( 75 | name = "User Not Found Example", 76 | summary = "Example of a user not found response", 77 | value = """{ 78 | "errorCode": "USER_NOT_FOUND", 79 | "errorMessage": "User with the given ID does not exist." 80 | }""" 81 | )] 82 | )] 83 | ) 84 | @ApiResponse( 85 | responseCode = "500", 86 | description = "Internal server error. An unexpected error occurred while processing the request.", 87 | content = [Content( 88 | mediaType = MediaType.APPLICATION_JSON_VALUE, 89 | schema = Schema(implementation = ErrorResponse::class), 90 | examples = [ExampleObject( 91 | name = "Server Error Example", 92 | summary = "Example of an internal server error response", 93 | value = """{ 94 | "errorCode": "INTERNAL_SERVER_ERROR", 95 | "errorMessage": "An unexpected error occurred while processing the request." 96 | }""" 97 | )] 98 | )] 99 | ) 100 | @GetMapping( 101 | produces = [MediaType.APPLICATION_JSON_VALUE] 102 | ) 103 | fun getUserDetails(): ResponseEntity { 104 | val userDetails = userDetailsProvider.getUserDetails() 105 | return ResponseEntity.ok(userDetails) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/dto/ShortenUrlRequest.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Request payload for shortening a URL") 6 | data class ShortenUrlRequest( 7 | 8 | @Schema(description = "The original URL to be shortened", 9 | example = "https://iced-latte.uk/") 10 | val originalUrl: String, 11 | 12 | @Schema(description = "Optional expiration time in days for the shortened URL (min 1, max 365)", 13 | example = "30") 14 | val daysCount: Long? 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/dto/UrlMappingDto.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.dto 2 | 3 | import com.zufar.urlshortener.shorten.entity.UrlMapping 4 | import io.swagger.v3.oas.annotations.media.Schema 5 | import java.time.LocalDateTime 6 | 7 | @Schema(description = "Data transfer object for URL mapping") 8 | data class UrlMappingDto( 9 | 10 | @Schema(description = "Unique hash for the shortened URL", example = "abc123") 11 | val urlHash: String, 12 | 13 | @Schema(description = "Shortened URL", example = "http://short.url/abc123") 14 | val shortUrl: String, 15 | 16 | @Schema(description = "Original URL", example = "http://example.com") 17 | val originalUrl: String, 18 | 19 | @Schema(description = "Creation timestamp", example = "2023-10-15T12:34:56") 20 | val createdAt: LocalDateTime, 21 | 22 | @Schema(description = "Expiration timestamp", example = "2023-11-15T12:34:56") 23 | val expirationDate: LocalDateTime 24 | ) { 25 | companion object { 26 | fun fromEntity(entity: UrlMapping): UrlMappingDto { 27 | return UrlMappingDto( 28 | urlHash = entity.urlHash, 29 | shortUrl = entity.shortUrl, 30 | originalUrl = entity.originalUrl, 31 | createdAt = entity.createdAt, 32 | expirationDate = entity.expirationDate 33 | ) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/dto/UrlMappingPageDto.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Paginated list of URL mappings") 6 | data class UrlMappingPageDto( 7 | 8 | @Schema(description = "List of URL mappings") 9 | val content: List, 10 | 11 | @Schema(description = "Current page number", example = "0") 12 | val page: Int, 13 | 14 | @Schema(description = "Number of items per page", example = "10") 15 | val size: Int, 16 | 17 | @Schema(description = "Total number of elements", example = "100") 18 | val totalElements: Long, 19 | 20 | @Schema(description = "Total number of pages", example = "10") 21 | val totalPages: Int 22 | ) 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/dto/UrlResponse.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Response payload containing the shortened URL") 6 | data class UrlResponse( 7 | 8 | @Schema(description = "The shortened URL that corresponds to the original URL", 9 | example = "https://short-link.zufargroup.com/65DcFj") 10 | val shortUrl: String 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/dto/UserDetailsDto.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.dto 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema 4 | 5 | @Schema(description = "Details about a user") 6 | data class UserDetailsDto( 7 | 8 | @Schema(description = "User's first name", example = "John") 9 | val firstName: String, 10 | 11 | @Schema(description = "User's last name", example = "Doe") 12 | val lastName: String, 13 | 14 | @Schema(description = "User's email address", example = "john.doe@example.com") 15 | val email: String, 16 | 17 | @Schema(description = "User's country", example = "USA") 18 | val country: String, 19 | 20 | @Schema(description = "User's age", example = "30") 21 | val age: Int 22 | ) 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/entity/UrlMapping.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.entity 2 | 3 | import org.springframework.data.annotation.Id 4 | import org.springframework.data.mongodb.core.mapping.Document 5 | import java.time.LocalDateTime 6 | 7 | @Document(collection = "url_mappings") 8 | data class UrlMapping( 9 | 10 | @Id 11 | val urlHash: String, 12 | val shortUrl: String, 13 | val originalUrl: String, 14 | 15 | // URL metadata 16 | val createdAt: LocalDateTime, 17 | val expirationDate: LocalDateTime, 18 | 19 | // Request-related metadata 20 | val requestIp: String?, 21 | val userAgent: String?, 22 | val referer: String?, 23 | val acceptLanguage: String?, 24 | val httpMethod: String, 25 | 26 | // Reference to the user who created this mapping 27 | val userId: String? 28 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/exception/UrlNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.exception 2 | 3 | class UrlNotFoundException(message: String) : RuntimeException(message) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/repository/UrlRepository.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.repository 2 | 3 | import com.zufar.urlshortener.shorten.entity.UrlMapping 4 | import org.springframework.data.domain.Page 5 | import org.springframework.data.domain.Pageable 6 | import org.springframework.data.mongodb.repository.MongoRepository 7 | import java.util.Optional 8 | 9 | interface UrlRepository : MongoRepository { 10 | 11 | fun findByUrlHash(urlHash: String): Optional 12 | 13 | fun findAllByUserId(userId: String, pageable: Pageable): Page 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/CorrelationIdFilter.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import jakarta.servlet.Filter 4 | import jakarta.servlet.FilterChain 5 | import jakarta.servlet.ServletRequest 6 | import jakarta.servlet.ServletResponse 7 | import jakarta.servlet.http.HttpServletResponse 8 | import org.slf4j.MDC 9 | import org.springframework.core.annotation.Order 10 | import org.springframework.stereotype.Component 11 | import java.util.UUID 12 | 13 | @Component 14 | @Order(1) 15 | class CorrelationIdFilter : Filter { 16 | 17 | override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { 18 | val httpResponse = response as HttpServletResponse 19 | val correlationId = generateCorrelationId() 20 | 21 | MDC.put("correlationId", correlationId) 22 | 23 | httpResponse.setHeader("X-Correlation-ID", correlationId) 24 | 25 | try { 26 | chain.doFilter(request, response) 27 | } finally { 28 | MDC.clear() 29 | } 30 | } 31 | 32 | private fun generateCorrelationId(): String { 33 | return UUID.randomUUID().toString() 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/DaysCountValidator.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import org.springframework.stereotype.Service 4 | 5 | private const val MIN_ALLOWED_DAYS_COUNT = 1L 6 | private const val MAX_ALLOWED_DAYS_COUNT = 365L 7 | 8 | @Service 9 | class DaysCountValidator { 10 | 11 | fun validateDaysCount(daysCount: Long?) { 12 | if (daysCount == null) { 13 | return 14 | } 15 | require(daysCount >= MIN_ALLOWED_DAYS_COUNT) { "Days count must be at least $MIN_ALLOWED_DAYS_COUNT day(s)." } 16 | require(daysCount <= MAX_ALLOWED_DAYS_COUNT) { "Days count must not exceed $MAX_ALLOWED_DAYS_COUNT day(s)." } 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/PageableUrlMappingsProvider.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.auth.repository.UserRepository 4 | import com.zufar.urlshortener.shorten.dto.UrlMappingDto 5 | import com.zufar.urlshortener.shorten.dto.UrlMappingPageDto 6 | import com.zufar.urlshortener.shorten.repository.UrlRepository 7 | import org.springframework.data.domain.PageRequest 8 | import org.springframework.security.core.context.SecurityContextHolder 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class PageableUrlMappingsProvider( 13 | private val urlRepository: UrlRepository, 14 | private val userRepository: UserRepository 15 | ) { 16 | 17 | fun getUrlMappingsPage(page: Int, size: Int): UrlMappingPageDto { 18 | val pageable = PageRequest.of(page, size) 19 | 20 | // Retrieve the authenticated user from SecurityContextHolder 21 | val authentication = SecurityContextHolder.getContext().authentication 22 | val email = authentication?.name ?: throw IllegalStateException("User is not authenticated") 23 | val user = userRepository.findByEmail(email) ?: throw IllegalStateException("User not found") 24 | val userId = user.id ?: throw IllegalStateException("User ID is missing") 25 | 26 | // Fetch URL mappings for the user 27 | val urlMappingsPage = urlRepository.findAllByUserId(userId, pageable) 28 | 29 | return UrlMappingPageDto( 30 | content = urlMappingsPage.content.map { UrlMappingDto.fromEntity(it) }, 31 | page = urlMappingsPage.number, 32 | size = urlMappingsPage.size, 33 | totalElements = urlMappingsPage.totalElements, 34 | totalPages = urlMappingsPage.totalPages 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/StringEncoder.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import java.nio.charset.StandardCharsets 4 | import java.util.zip.CRC32 5 | 6 | class StringEncoder { 7 | 8 | companion object { 9 | private const val BASE_58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 10 | private const val BASE = 58 11 | 12 | fun encode(input: String): String { 13 | val id = stringToLong(input) 14 | val encoded = StringBuilder() 15 | 16 | var number = id 17 | while (number > 0) { 18 | val remainder = (number % BASE).toInt() 19 | encoded.insert(0, BASE_58_ALPHABET[remainder]) 20 | number /= BASE 21 | } 22 | 23 | return encoded.toString() 24 | } 25 | 26 | private fun stringToLong(input: String): Long { 27 | val crc = CRC32() 28 | crc.update(input.toByteArray(StandardCharsets.UTF_8)) 29 | return crc.value 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UrlDeleter.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.shorten.exception.UrlNotFoundException 4 | import com.zufar.urlshortener.shorten.repository.UrlRepository 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class UrlDeleter( 10 | private val urlRepository: UrlRepository 11 | ) { 12 | private val log = LoggerFactory.getLogger(UrlDeleter::class.java) 13 | 14 | fun deleteUrl(urlHash: String) { 15 | log.info("Attempting to delete URL mapping for urlHash='{}'", urlHash) 16 | if (urlRepository.existsById(urlHash)) { 17 | log.info("Found URL mapping for urlHash='{}'. Deleting...", urlHash) 18 | urlRepository.deleteById(urlHash) 19 | log.info("Successfully deleted URL mapping for urlHash='{}'", urlHash) 20 | } else { 21 | log.warn("No URL mapping found for urlHash='{}'. Deletion failed.", urlHash) 22 | throw UrlNotFoundException("No URL mapping found for urlHash='$urlHash'. Deletion failed.") 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UrlExpirationTimeDeletionScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.shorten.repository.UrlRepository 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.scheduling.annotation.Scheduled 6 | import org.springframework.stereotype.Component 7 | import java.time.LocalDateTime 8 | 9 | @Component 10 | class UrlExpirationTimeDeletionScheduler( 11 | private val urlRepository: UrlRepository 12 | ) { 13 | private val log = LoggerFactory.getLogger(UrlExpirationTimeDeletionScheduler::class.java) 14 | 15 | @Scheduled(cron = "0 0 0 * * *")// Run once every 24 hours at midnight 16 | fun deleteExpiredUrls() { 17 | log.info("Checking for expired URLs") 18 | 19 | val expiredUrls = urlRepository.findAll().filter { it.expirationDate.isBefore(LocalDateTime.now()) } 20 | if (expiredUrls.isNotEmpty()) { 21 | log.info("Found {} expired URLs, deleting them...", expiredUrls.size) 22 | urlRepository.deleteAll(expiredUrls) 23 | } else { 24 | log.info("No expired URLs found.") 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UrlMappingEntityCreator.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.auth.repository.UserRepository 4 | import com.zufar.urlshortener.shorten.dto.ShortenUrlRequest 5 | import com.zufar.urlshortener.shorten.entity.UrlMapping 6 | import jakarta.servlet.http.HttpServletRequest 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.security.core.context.SecurityContextHolder 9 | import org.springframework.stereotype.Service 10 | import java.time.LocalDateTime 11 | 12 | private const val DEFAULT_EXPIRATION_URL_DAYS = 365L 13 | 14 | @Service 15 | class UrlMappingEntityCreator(private val userRepository: UserRepository) { 16 | 17 | private val log = LoggerFactory.getLogger(UrlMappingEntityCreator::class.java) 18 | 19 | fun create(shortenUrlRequest: ShortenUrlRequest, 20 | httpServletRequest: HttpServletRequest, 21 | urlHash: String, 22 | shortUrl: String): UrlMapping { 23 | 24 | val urlMapping = UrlMapping( 25 | urlHash = urlHash, 26 | shortUrl = shortUrl, 27 | originalUrl = shortenUrlRequest.originalUrl, 28 | createdAt = LocalDateTime.now(), 29 | expirationDate = LocalDateTime.now().plusDays(shortenUrlRequest.daysCount ?: DEFAULT_EXPIRATION_URL_DAYS), 30 | requestIp = httpServletRequest.remoteAddr, 31 | userAgent = httpServletRequest.getHeader("User-Agent"), 32 | referer = httpServletRequest.getHeader("Referer"), 33 | acceptLanguage = httpServletRequest.getHeader("Accept-Language"), 34 | httpMethod = httpServletRequest.method, 35 | userId = getUserId() 36 | ) 37 | 38 | log.debug( 39 | "Created URL mapping: urlHash='{}', shortUrl='{}', originalUrl='{}', createdAt='{}', expirationDate='{}', " + 40 | "requestIp='{}', userAgent='{}', referer='{}', acceptLanguage='{}', httpMethod='{}', userId='{}'", 41 | urlHash, 42 | shortUrl, 43 | shortenUrlRequest.originalUrl, 44 | urlMapping.createdAt, 45 | urlMapping.expirationDate, 46 | urlMapping.requestIp, 47 | urlMapping.userAgent, 48 | urlMapping.referer, 49 | urlMapping.acceptLanguage, 50 | urlMapping.httpMethod, 51 | urlMapping.userId 52 | ) 53 | 54 | return urlMapping 55 | } 56 | 57 | private fun getUserId(): String? { 58 | val authentication = SecurityContextHolder.getContext().authentication 59 | val email = authentication?.name ?: throw IllegalStateException("User is not authenticated") 60 | var userId: String? = null 61 | if ("anonymousUser" != email) { 62 | val user = userRepository.findByEmail(email) ?: throw IllegalStateException("User not found") 63 | userId = user.id 64 | } 65 | return userId 66 | } 67 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UrlMappingProvider.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.shorten.dto.UrlMappingDto 4 | import com.zufar.urlshortener.shorten.exception.UrlNotFoundException 5 | import com.zufar.urlshortener.shorten.repository.UrlRepository 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class UrlMappingProvider(private val urlRepository: UrlRepository) { 10 | 11 | fun getUrlMappingByHash(urlHash: String): UrlMappingDto { 12 | return urlRepository.findByUrlHash(urlHash) 13 | .map(UrlMappingDto::fromEntity) 14 | .orElseThrow { UrlNotFoundException("URL mapping not found") } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UrlShortener.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.shorten.dto.ShortenUrlRequest 4 | import com.zufar.urlshortener.shorten.repository.UrlRepository 5 | import jakarta.servlet.http.HttpServletRequest 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.beans.factory.annotation.Value 8 | import org.springframework.stereotype.Service 9 | 10 | @Service 11 | class UrlShortener( 12 | private val urlRepository: UrlRepository, 13 | private val urlValidator: UrlValidator, 14 | private val daysCountValidator: DaysCountValidator, 15 | private val urlMappingEntityCreator: UrlMappingEntityCreator, 16 | ) { 17 | private val log = LoggerFactory.getLogger(UrlShortener::class.java) 18 | 19 | @Value("\${app.base-url}") 20 | private lateinit var baseUrl: String 21 | 22 | fun shortenUrl(shortenUrlRequest: ShortenUrlRequest, 23 | httpServletRequest: HttpServletRequest): String { 24 | 25 | val originalUrl = shortenUrlRequest.originalUrl 26 | val clientIp = httpServletRequest.remoteAddr 27 | val userAgent = httpServletRequest.getHeader("User-Agent") 28 | 29 | log.info("Trying to shorten originalURL='{}' from IP='{}', User-Agent='{}'", originalUrl, clientIp, userAgent) 30 | 31 | urlValidator.validateUrl(originalUrl) 32 | daysCountValidator.validateDaysCount(shortenUrlRequest.daysCount) 33 | log.debug("URL validation passed for originalURL='{}'", originalUrl) 34 | 35 | val urlHash = StringEncoder.encode(originalUrl) 36 | log.debug("Encoded originalURL='{}' to urlHash='{}'", originalUrl, urlHash) 37 | 38 | val newShortUrl = "$baseUrl/url/$urlHash" 39 | log.info("Generated new shortURL='{}' for originalURL='{}'", newShortUrl, originalUrl) 40 | 41 | val urlMapping = urlMappingEntityCreator.create(shortenUrlRequest, httpServletRequest, urlHash, newShortUrl) 42 | urlRepository.save(urlMapping) 43 | log.info("Saved URL mapping for urlHash='{}' in MongoDB", urlHash) 44 | 45 | return newShortUrl 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UrlValidator.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import org.springframework.stereotype.Service 4 | import java.net.URI 5 | import java.net.URL 6 | 7 | private const val MAX_ALLOWED_URL_LENGTH = 2048 8 | 9 | @Service 10 | class UrlValidator { 11 | private val allowedProtocols = setOf("http", "https") 12 | private val loopbackAddresses = setOf("localhost", "127.0.0.1", "::1", "short-link.zufargroup.com") 13 | private val validator = org.apache.commons.validator.routines.UrlValidator(allowedProtocols.toTypedArray()) 14 | 15 | fun validateUrl(url: String) { 16 | require(url.isNotBlank()) { "URL must not be empty or blank." } 17 | require(!url.contains(" ")) { "URL must not contain spaces." } 18 | require(url.length <= MAX_ALLOWED_URL_LENGTH) { "URL exceeds the maximum allowed length of $MAX_ALLOWED_URL_LENGTH characters." } 19 | require(hasValidProtocol(url)) { "URL must have a proper scheme (http or https)." } 20 | require(validator.isValid(url)) { "URL is not valid. Please ensure it has the correct format and syntax." } 21 | require(isValidHost(url)) { "URL must contain a valid host. Loopback addresses (localhost, 127.0.0.1, ::1, short-link.zufargroup.com) are not allowed." } 22 | } 23 | 24 | private fun hasValidProtocol(url: String): Boolean { 25 | return allowedProtocols.any { url.startsWith("$it://") } 26 | } 27 | 28 | private fun isValidHost(url: String): Boolean { 29 | return try { 30 | val parsedUrl: URL = URI(url).toURL() 31 | val host = parsedUrl.host 32 | !host.isNullOrBlank() && host !in loopbackAddresses 33 | } catch (e: Exception) { 34 | false 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/zufar/urlshortener/shorten/service/UserDetailsProvider.kt: -------------------------------------------------------------------------------- 1 | package com.zufar.urlshortener.shorten.service 2 | 3 | import com.zufar.urlshortener.auth.repository.UserRepository 4 | import com.zufar.urlshortener.shorten.dto.UserDetailsDto 5 | import org.springframework.security.core.context.SecurityContextHolder 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class UserDetailsProvider(private val userRepository: UserRepository) { 10 | 11 | fun getUserDetails(): UserDetailsDto { 12 | val authentication = SecurityContextHolder.getContext().authentication 13 | val email = authentication?.name ?: throw IllegalStateException("User is not authenticated") 14 | val user = userRepository.findByEmail(email) ?: throw IllegalStateException("User not found") 15 | 16 | return UserDetailsDto( 17 | firstName = user.firstName, 18 | lastName = user.lastName, 19 | email = user.email, 20 | country = user.country, 21 | age = user.age 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | 2 | # Application Properties 3 | spring.application.name=URL-Shortener 4 | app.base-url=${SERVER_BASE_URL} 5 | server.port=8080 6 | 7 | # Springdoc Configuration 8 | springdoc.swagger-ui.path=/api/v1/swagger-ui 9 | springdoc.api-docs.path=/api/v1/api-docs 10 | springdoc.api-docs.enabled=true 11 | springdoc.show-actuator=true 12 | springdoc.packages-to-scan=com.zufar.urlshortener 13 | 14 | # JWT Configuration 15 | jwt.secret=${JWT_SECRET} 16 | jwt.accessTokenExpiration=3600000 17 | jwt.refreshTokenExpiration=604800000 18 | 19 | # MongoDB Configuration 20 | spring.data.mongodb.uri=${MONGODB_URI} 21 | spring.data.mongodb.database=${MONGODB_DATABASE} 22 | 23 | # Actuator 24 | management.endpoints.web.exposure.include=* 25 | management.endpoints.web.base-path=/actuator 26 | management.endpoint.health.show-details=always 27 | 28 | # Logging 29 | logging.level.root=WARN 30 | logging.level.org.mongodb.driver=WARN 31 | logging.level.org.springframework.data.mongodb=WARN 32 | logging.level.org.springframework.web=WARN 33 | logging.level.com.zufar.urlshortener=INFO 34 | logging.file.name=logs/application.log 35 | logging.logback.rollingpolicy.max-file-size=10MB 36 | logging.logback.rollingpolicy.max-history=30 37 | logging.pattern.console="%d{yyyy-MM-dd HH:mm:ss.SSS} - correlationId=%X{correlationId} [%thread] %-5level %logger{36} - %msg%n" --------------------------------------------------------------------------------