├── .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 | [](https://github.com/Sunagatov/URL-Shortener/blob/main/LICENSE)
7 | [](https://github.com/Sunagatov/URL-Shortener/issues)
8 | [](https://github.com/Sunagatov/URL-Shortener)
9 | [](https://app.codecov.io/github/Sunagatov/URL-Shortener)
10 |
11 | [](https://github.com/Sunagatov/URL-Shortener/graphs/contributors)
12 | [](https://github.com/Sunagatov/URL-Shortener/stargazers)
13 | [](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"
--------------------------------------------------------------------------------