diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4733ca1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,258 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +gradle +gradlew +gradlew.bat + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +# Created by https://www.toptal.com/developers/gitignore/api/macos,gradle,intellij,java,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,gradle,intellij,java,windows + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +### Gradle Patch ### +# Java heap dump +*.hprof + +# End of https://www.toptal.com/developers/gitignore/api/macos,gradle,intellij,java,windows diff --git a/README.md b/README.md index a557279f..fa049c07 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ SprintBoot URL Shortener 구현 미션 Repository 입니다. ## 요구사항 각 요구사항을 모두 충족할 수 있도록 노력해봅시다. -- [ ] URL 입력폼 제공 및 결과 출력 -- [ ] URL Shortening Key는 8 Character 이내로 생성 -- [ ] 단축된 URL 요청시 원래 URL로 리다이렉트 +- [X] URL 입력폼 제공 및 결과 출력 +- [X] URL Shortening Key는 8 Character 이내로 생성 +- [X] 단축된 URL 요청시 원래 URL로 리다이렉트 - [ ] 단축된 URL에 대한 요청 수 정보저장 (optional) - [ ] Shortening Key를 생성하는 알고리즘 2개 이상 제공하며 애플리케이션 실행중 동적으로 변경 가능 (optional) diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..6a3b2167 --- /dev/null +++ b/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.4' + id 'io.spring.dependency-management' version '1.1.3' +} + +group = 'com.prgrms' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + // Spring Data JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // H2 Database + runtimeOnly 'com.h2database:h2' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Spring Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // Bean Validator + implementation 'org.springframework.boot:spring-boot-starter-validation' + +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..3ab648d0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'url-shortener' diff --git a/src/main/java/com/prgrms/urlshortener/UrlShortenerApplication.java b/src/main/java/com/prgrms/urlshortener/UrlShortenerApplication.java new file mode 100644 index 00000000..a474e8b2 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/UrlShortenerApplication.java @@ -0,0 +1,13 @@ +package com.prgrms.urlshortener; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class UrlShortenerApplication { + + public static void main(String[] args) { + SpringApplication.run(UrlShortenerApplication.class, args); + } + +} diff --git a/src/main/java/com/prgrms/urlshortener/controller/HomeController.java b/src/main/java/com/prgrms/urlshortener/controller/HomeController.java new file mode 100644 index 00000000..958e30e6 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/controller/HomeController.java @@ -0,0 +1,49 @@ +package com.prgrms.urlshortener.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; + +import com.prgrms.urlshortener.domain.dto.ShortenUrlRequest; +import com.prgrms.urlshortener.domain.dto.ShortenUrlResponse; +import com.prgrms.urlshortener.service.UrlShortenService; +import jakarta.validation.Valid; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class HomeController { + + private final UrlShortenService urlShortenService; + + @ExceptionHandler(NoSuchElementException.class) + public String handleNoSuchElementException() { + return "404"; + } + + @PostMapping("/shorten") + public String shorten(@Valid @ModelAttribute ShortenUrlRequest request, BindingResult bindingResult, Model model) { + if (bindingResult.hasErrors()) { + return "redirect:error"; + } + + ShortenUrlResponse response = urlShortenService.generateShortUrl(request); + model.addAttribute("shortenUrlResponse", response); + + return "shorten-result"; + } + + @GetMapping("/{shortUrl}") + public String getOriginalUrl(@PathVariable String shortUrl) { + log.info("shortUrl={}", shortUrl); + return "redirect:https://" + urlShortenService.getOriginalUrl(shortUrl); + } +} diff --git a/src/main/java/com/prgrms/urlshortener/domain/dto/ShortenUrlRequest.java b/src/main/java/com/prgrms/urlshortener/domain/dto/ShortenUrlRequest.java new file mode 100644 index 00000000..f09864b6 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/domain/dto/ShortenUrlRequest.java @@ -0,0 +1,15 @@ +package com.prgrms.urlshortener.domain.dto; + +import static com.prgrms.urlshortener.utils.Regexp.*; + +import com.prgrms.urlshortener.domain.entity.UrlInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ShortenUrlRequest( + @NotBlank @Pattern(regexp = URL) String url +) { + public UrlInfo toEntity() { + return new UrlInfo(url); + } +} diff --git a/src/main/java/com/prgrms/urlshortener/domain/dto/ShortenUrlResponse.java b/src/main/java/com/prgrms/urlshortener/domain/dto/ShortenUrlResponse.java new file mode 100644 index 00000000..c6f53f67 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/domain/dto/ShortenUrlResponse.java @@ -0,0 +1,7 @@ +package com.prgrms.urlshortener.domain.dto; + +public record ShortenUrlResponse( + String originalUrl, + String shortUrl +) { +} diff --git a/src/main/java/com/prgrms/urlshortener/domain/entity/UrlInfo.java b/src/main/java/com/prgrms/urlshortener/domain/entity/UrlInfo.java new file mode 100644 index 00000000..251f2d00 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/domain/entity/UrlInfo.java @@ -0,0 +1,51 @@ +package com.prgrms.urlshortener.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "url_info") +@SequenceGenerator( + name = "url_info_seq_generator", + sequenceName = "url_info_seq", + initialValue = 123456 +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UrlInfo { + + private static final String HTTPS = "https://"; + private static final String HTTP = "http://"; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "url_info_seq_generator") + @Column(name = "id") + Long id; + + @Column(name = "original_url", nullable = false) + private String originalUrl; + + public UrlInfo(String originalUrl) { + this.originalUrl = removeProtocol(originalUrl); + } + + private String removeProtocol(String originalUrl) { + if (originalUrl.startsWith(HTTPS)) { + return originalUrl.replace(HTTPS, ""); + } + + if (originalUrl.startsWith(HTTP)) { + return originalUrl.replace(HTTP, ""); + } + + return originalUrl; + } +} diff --git a/src/main/java/com/prgrms/urlshortener/domain/repository/UrlRepository.java b/src/main/java/com/prgrms/urlshortener/domain/repository/UrlRepository.java new file mode 100644 index 00000000..728dfb5c --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/domain/repository/UrlRepository.java @@ -0,0 +1,8 @@ +package com.prgrms.urlshortener.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.prgrms.urlshortener.domain.entity.UrlInfo; + +public interface UrlRepository extends JpaRepository { +} diff --git a/src/main/java/com/prgrms/urlshortener/service/UrlShortenService.java b/src/main/java/com/prgrms/urlshortener/service/UrlShortenService.java new file mode 100644 index 00000000..5334393a --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/service/UrlShortenService.java @@ -0,0 +1,38 @@ +package com.prgrms.urlshortener.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.prgrms.urlshortener.domain.dto.ShortenUrlRequest; +import com.prgrms.urlshortener.domain.dto.ShortenUrlResponse; +import com.prgrms.urlshortener.domain.entity.UrlInfo; +import com.prgrms.urlshortener.domain.repository.UrlRepository; +import com.prgrms.urlshortener.utils.Base62Util; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UrlShortenService { + + private final UrlRepository urlRepository; + + @Transactional + public ShortenUrlResponse generateShortUrl(ShortenUrlRequest request) { + UrlInfo urlInfo = request.toEntity(); + UrlInfo savedUrlInfo = urlRepository.save(urlInfo); + String shortUrl = Base62Util.encode(savedUrlInfo.getId()); + + return new ShortenUrlResponse(urlInfo.getOriginalUrl(), shortUrl); + } + + public String getOriginalUrl(String shortUrl) { + Long id = Base62Util.decode(shortUrl); + UrlInfo savedUrlInfo = urlRepository.findById(id).orElseThrow( + () -> new NoSuchElementException("URL not found on DB") + ); + + return savedUrlInfo.getOriginalUrl(); + } +} diff --git a/src/main/java/com/prgrms/urlshortener/utils/Base62Util.java b/src/main/java/com/prgrms/urlshortener/utils/Base62Util.java new file mode 100644 index 00000000..59720c10 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/utils/Base62Util.java @@ -0,0 +1,36 @@ +package com.prgrms.urlshortener.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public abstract class Base62Util { + + private static final int BASE62 = 62; + private static final String BASE62_CHAR = "aZbYc0XdWeV1fUgTh2SiRjQ3kPlOm4NnMoL5pKqJr6IsHtG7uFvEw8DxCyB9zA"; + private static final String PREFIX = "localhost:8080/"; + + public static String encode(Long value) { + final StringBuilder sb = new StringBuilder(); + sb.append(PREFIX); + + do { + sb.append(BASE62_CHAR.charAt((int)(value % BASE62))); + value /= BASE62; + } while (value > 0); + + return sb.toString(); + } + + public static Long decode(String value) { + long result = 0L; + int power = 1; + + for (int i = 0; i < value.length(); i++) { + result += (long)BASE62_CHAR.indexOf(value.charAt(i)) * power; + power *= BASE62; + } + + return result; + } +} diff --git a/src/main/java/com/prgrms/urlshortener/utils/Regexp.java b/src/main/java/com/prgrms/urlshortener/utils/Regexp.java new file mode 100644 index 00000000..4b8f3394 --- /dev/null +++ b/src/main/java/com/prgrms/urlshortener/utils/Regexp.java @@ -0,0 +1,10 @@ +package com.prgrms.urlshortener.utils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public abstract class Regexp { + + public static final String URL = "[-a-zA-Z0-9@:%_\\+.~#?&//=]{2,256}\\.[a-z]{2,4}\\b(\\/[-a-zA-Z0-9@:%_\\+.~#?&//=]*)?"; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..0eadbd61 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + datasource: + url: jdbc:h2:mem:shorten;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE; + username: sa + password: + driver-class-name: org.h2.Driver + + h2: + console: + enabled: true + path: /h2-console + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + +logging.level: + org.hibernate.SQL: debug diff --git a/src/main/resources/templates/404.html b/src/main/resources/templates/404.html new file mode 100644 index 00000000..cb92acc9 --- /dev/null +++ b/src/main/resources/templates/404.html @@ -0,0 +1,15 @@ + + + + + Error 404 + + + +

올바른 단축 URL이 아닙니다

+
+ 돌아가기 +
+ + + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 00000000..c1990f8f --- /dev/null +++ b/src/main/resources/templates/error.html @@ -0,0 +1,15 @@ + + + + + Error + + + +

올바른 URL을 입력해주세요.

+
+ 돌아가기 +
+ + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 00000000..bcff188b --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,74 @@ + + + + + 김앵맹의 URL Shortener + + + + +

⭐️ 김앵맹의 URL Shortener ⭐️

+ +
+

줄이고 싶은 URL을 입력하세요

+
+
+ +
+ +
+
+
+
+ + + diff --git a/src/main/resources/templates/shorten-result.html b/src/main/resources/templates/shorten-result.html new file mode 100644 index 00000000..05e8530e --- /dev/null +++ b/src/main/resources/templates/shorten-result.html @@ -0,0 +1,114 @@ + + + + + 김앵맹의 단축된 URL + + + + + +

⭐️김앵맹의 단축된 URL!!!⭐️

+ +
+
+
+
+ +
+ +
+
+
+
+
+ 원본 URL: +
+
+ 뒤로 가기 +
+ +
+ + + + + diff --git a/src/test/java/com/prgrms/urlshortener/UrlShortenerApplicationTests.java b/src/test/java/com/prgrms/urlshortener/UrlShortenerApplicationTests.java new file mode 100644 index 00000000..9d8c69fc --- /dev/null +++ b/src/test/java/com/prgrms/urlshortener/UrlShortenerApplicationTests.java @@ -0,0 +1,13 @@ +package com.prgrms.urlshortener; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class UrlShortenerApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/prgrms/urlshortener/domain/entity/UrlInfoTest.java b/src/test/java/com/prgrms/urlshortener/domain/entity/UrlInfoTest.java new file mode 100644 index 00000000..64df794c --- /dev/null +++ b/src/test/java/com/prgrms/urlshortener/domain/entity/UrlInfoTest.java @@ -0,0 +1,29 @@ +package com.prgrms.urlshortener.domain.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class UrlInfoTest { + + @Test + @DisplayName("HTTPS://로 시작하는 주소 필터 테스트") + void remove_https_test() { + // given, when + UrlInfo urlInfo = new UrlInfo("https://www.google.com"); + + // then + assertThat(urlInfo.getOriginalUrl()).isEqualTo("www.google.com"); + } + + @Test + @DisplayName("HTTP://로 시작하는 주소 필터 테스트") + void remove_http_test() { + // given, when + UrlInfo urlInfo = new UrlInfo("http://www.google.com"); + + // then + assertThat(urlInfo.getOriginalUrl()).isEqualTo("www.google.com"); + } +} diff --git a/src/test/java/com/prgrms/urlshortener/domain/repository/UrlRepositoryTest.java b/src/test/java/com/prgrms/urlshortener/domain/repository/UrlRepositoryTest.java new file mode 100644 index 00000000..e37510dd --- /dev/null +++ b/src/test/java/com/prgrms/urlshortener/domain/repository/UrlRepositoryTest.java @@ -0,0 +1,30 @@ +package com.prgrms.urlshortener.domain.repository; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.prgrms.urlshortener.domain.entity.UrlInfo; + +@SpringBootTest +class UrlRepositoryTest { + + @Autowired + UrlRepository repository; + + @Test + @DisplayName("Database sequence 초기값 123456 확인") + void sequence_initial_of_123456_test() { + // given + UrlInfo urlInfo = new UrlInfo("www.google.com"); + + // when + UrlInfo savedUrlInfo = repository.save(urlInfo); + + // then + assertThat(savedUrlInfo.getId()).isEqualTo(123456); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..5c56261a --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: jdbc:h2:mem:shorten;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE; + username: sa + password: + driver-class-name: org.h2.Driver