diff --git a/short-url/.gitignore b/short-url/.gitignore new file mode 100644 index 00000000..7fcb443b --- /dev/null +++ b/short-url/.gitignore @@ -0,0 +1,183 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,intellij+iml,java,gradle +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,intellij+iml,java,gradle + +### Intellij+iml ### +# 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 + +# File-based project format +*.iws + +# IntelliJ +out/ + +# 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+iml Patch ### +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +### 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 + +### 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 + +# Eclipse Gradle plugin generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Gradle Patch ### +# Java heap dump +*.hprof + +## added files +gradlew* +HELP.md +.idea + +# End of https://www.toptal.com/developers/gitignore/api/macos,intellij+iml,java,gradle + diff --git a/short-url/build.gradle b/short-url/build.gradle new file mode 100644 index 00000000..30bf1a8d --- /dev/null +++ b/short-url/build.gradle @@ -0,0 +1,58 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.4' + id 'io.spring.dependency-management' version '1.1.3' + id 'org.asciidoctor.jvm.convert' version '3.3.2' +} + +group = 'com.prgrms.wonu606' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + set('snippetsDir', file("build/generated-snippets")) +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation ('commons-validator:commons-validator:1.7') { + exclude group: 'commons-collections', module: 'commons-collections' + } + implementation 'org.apache.commons:commons-collections4:4.4' + implementation 'org.apache.commons:commons-lang3:3.0' + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + + compileOnly 'org.projectlombok:lombok' + compileOnly 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +tasks.named('test') { + outputs.dir snippetsDir + useJUnitPlatform() +} + +tasks.named('asciidoctor') { + inputs.dir snippetsDir + dependsOn test +} diff --git a/short-url/gradle/wrapper/gradle-wrapper.jar b/short-url/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..033e24c4 Binary files /dev/null and b/short-url/gradle/wrapper/gradle-wrapper.jar differ diff --git a/short-url/gradle/wrapper/gradle-wrapper.properties b/short-url/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..9f4197d5 --- /dev/null +++ b/short-url/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/short-url/settings.gradle b/short-url/settings.gradle new file mode 100644 index 00000000..c317cafe --- /dev/null +++ b/short-url/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'short-url' diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/ShortUrlApplication.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/ShortUrlApplication.java new file mode 100644 index 00000000..cc2cbf98 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/ShortUrlApplication.java @@ -0,0 +1,13 @@ +package com.prgrms.wonu606.shorturl; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ShortUrlApplication { + + public static void main(String[] args) { + SpringApplication.run(ShortUrlApplication.class, args); + } + +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/ShortUrlApiController.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/ShortUrlApiController.java new file mode 100644 index 00000000..02ccb940 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/ShortUrlApiController.java @@ -0,0 +1,27 @@ +package com.prgrms.wonu606.shorturl.controller; + +import com.prgrms.wonu606.shorturl.service.UrlShortenerService; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class ShortUrlApiController { + + private final UrlShortenerService urlShortenerService; + + @GetMapping("/{shortUrl}") + public ResponseEntity redirect(@PathVariable String shortUrl) { + String originalUrl = urlShortenerService.getOriginalUrlByShortUrl(shortUrl); + + return ResponseEntity + .status(HttpStatus.FOUND) + .location(URI.create(originalUrl)) + .build(); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerApiController.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerApiController.java new file mode 100644 index 00000000..aad7694b --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerApiController.java @@ -0,0 +1,55 @@ +package com.prgrms.wonu606.shorturl.controller; + +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateRequest; +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateResponse; +import com.prgrms.wonu606.shorturl.controller.mapper.UrlShortenerApiParamMapper; +import com.prgrms.wonu606.shorturl.controller.mapper.UrlShortenerApiResponseMapper; +import com.prgrms.wonu606.shorturl.service.UrlShortenerService; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateParam; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateResult; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/shorten-url") +public class UrlShortenerApiController { + + private final UrlShortenerService urlShortenerService; + private final UrlShortenerApiParamMapper paramMapper; + private final UrlShortenerApiResponseMapper responseMapper; + private final String baseUrl; + + public UrlShortenerApiController( + UrlShortenerService urlShortenerService, + UrlShortenerApiParamMapper paramMapper, + UrlShortenerApiResponseMapper responseMapper, + @Value("${url-shortener.base-url}") String baseUrl) { + this.urlShortenerService = urlShortenerService; + this.paramMapper = paramMapper; + this.responseMapper = responseMapper; + this.baseUrl = baseUrl; + } + + @PostMapping( + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity findOrCreateShortenedUrl( + @RequestBody @Valid ShortenUrlCreateRequest request) { + ShortenUrlCreateParam param = paramMapper.toShortenUrlCreateParam(request); + + ShortenUrlCreateResult result = urlShortenerService.findOrCreateShortenUrlHash(param); + + HttpStatus httpStatus = result.isNew() ? HttpStatus.CREATED : HttpStatus.OK; + return ResponseEntity + .status(httpStatus) + .body(responseMapper.toShortenUrlCreateResponse(result, baseUrl)); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerController.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerController.java new file mode 100644 index 00000000..8e321aa1 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerController.java @@ -0,0 +1,13 @@ +package com.prgrms.wonu606.shorturl.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class UrlShortenerController { + + @GetMapping("/") + public String displayIndexPage() { + return "index"; + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/dto/ShortenUrlCreateRequest.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/dto/ShortenUrlCreateRequest.java new file mode 100644 index 00000000..18466547 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/dto/ShortenUrlCreateRequest.java @@ -0,0 +1,11 @@ +package com.prgrms.wonu606.shorturl.controller.dto; + +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; + +public record ShortenUrlCreateRequest( + @NotBlank(message = "URL은 null이거나 공백일 수 없습니다.") + @Length(max = 2000, message = "URL 길이는 2000자를 넘길 수 없습니다.") + String originalUrl) { + +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/dto/ShortenUrlCreateResponse.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/dto/ShortenUrlCreateResponse.java new file mode 100644 index 00000000..26bddd89 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/dto/ShortenUrlCreateResponse.java @@ -0,0 +1,5 @@ +package com.prgrms.wonu606.shorturl.controller.dto; + +public record ShortenUrlCreateResponse(String shortenUrl) { + +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/mapper/UrlShortenerApiParamMapper.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/mapper/UrlShortenerApiParamMapper.java new file mode 100644 index 00000000..0aa5c638 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/mapper/UrlShortenerApiParamMapper.java @@ -0,0 +1,15 @@ +package com.prgrms.wonu606.shorturl.controller.mapper; + +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateRequest; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateParam; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface UrlShortenerApiParamMapper { + + ShortenUrlCreateParam toShortenUrlCreateParam(ShortenUrlCreateRequest request); +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/mapper/UrlShortenerApiResponseMapper.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/mapper/UrlShortenerApiResponseMapper.java new file mode 100644 index 00000000..26e68da7 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/controller/mapper/UrlShortenerApiResponseMapper.java @@ -0,0 +1,17 @@ +package com.prgrms.wonu606.shorturl.controller.mapper; + +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateResponse; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateResult; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface UrlShortenerApiResponseMapper { + + @Mapping(target = "shortenUrl", expression = "java(baseUrl + result.hashedShortUrl())") + ShortenUrlCreateResponse toShortenUrlCreateResponse(ShortenUrlCreateResult result, String baseUrl); +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/Url.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/Url.java new file mode 100644 index 00000000..8c1fd15b --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/Url.java @@ -0,0 +1,21 @@ +package com.prgrms.wonu606.shorturl.domain; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.validator.routines.UrlValidator; + +public record Url(String value) { + + private static final UrlValidator URL_VALIDATOR = new UrlValidator(); + public static final int URL_MAX_LENGTH = 2_000; + + public Url { + if (!URL_VALIDATOR.isValid(value)) { + throw new IllegalArgumentException("잘못된 URL 주소입니다. Current URL: " + value); + } + + if (StringUtils.length(value) > URL_MAX_LENGTH) { + throw new IllegalArgumentException( + String.format("URL의 길이는 %d를 넘어갈 수 없습니다 Current Length: %d", URL_MAX_LENGTH, value.length())); + } + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/UrlHash.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/UrlHash.java new file mode 100644 index 00000000..676c9fb5 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/UrlHash.java @@ -0,0 +1,25 @@ +package com.prgrms.wonu606.shorturl.domain; + +import org.apache.commons.lang3.StringUtils; + +public record UrlHash( + String value +) { + + private static final int MAX_LENGTH = 8; + + public UrlHash { + validate(value); + } + + private void validate(String value) { + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException("해시 값은 null이거나 빈 문자열일 수 없습니다."); + } + if (StringUtils.length(value) > MAX_LENGTH) { + throw new IllegalArgumentException( + String.format("단축 URL 해시는 %d자 이하여야 합니다. Current Length: %d", MAX_LENGTH, value.length()) + ); + } + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/UrlLink.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/UrlLink.java new file mode 100644 index 00000000..8f3ca88c --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/domain/UrlLink.java @@ -0,0 +1,26 @@ +package com.prgrms.wonu606.shorturl.domain; + +import lombok.Getter; + +@Getter +public class UrlLink { + + private Long id; + private final Url originalUrl; + private final UrlHash urlHash; + + public UrlLink(Url originalUrl, UrlHash urlHash) { + this.originalUrl = originalUrl; + this.urlHash = urlHash; + } + + public void initializeId(Long newId) { + if (newId == null || newId <= 0) { + throw new IllegalArgumentException("ID는 0보다 크고 null이 아니어야 합니다. Input ID: " + newId); + } + if (this.id != null) { + throw new IllegalStateException("ID가 이미 할당되어 있습니다. Current ID: " + newId); + } + this.id = newId; + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/repository/LocalUrlLinkRepository.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/repository/LocalUrlLinkRepository.java new file mode 100644 index 00000000..e7ef7e5d --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/repository/LocalUrlLinkRepository.java @@ -0,0 +1,71 @@ +package com.prgrms.wonu606.shorturl.repository; + +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.domain.UrlLink; +import com.prgrms.wonu606.shorturl.service.UrlLinkRepository; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.stereotype.Component; + +@Component +public class LocalUrlLinkRepository implements UrlLinkRepository { + + private final Map storage = new ConcurrentHashMap<>(); + private final Map hashedUrlIndex = new ConcurrentHashMap<>(); + + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Long save(UrlLink urlLink) { + validateUrlLink(urlLink); + + Long newId = generateNewId(); + + urlLink.initializeId(newId); + + storage.put(newId, urlLink); + + hashedUrlIndex.compute(urlLink.getUrlHash(), (key, existingUrlLink) -> { + if (existingUrlLink != null) { + throw new IllegalArgumentException("UrlHash가 이미 인덱싱 되어 있습니다. Current UrlHash: " + urlLink.getUrlHash()); + } + return urlLink; + }); + + return newId; + } + + @Override + public boolean existByUrlHash(UrlHash urlHash) { + return hashedUrlIndex.containsKey(urlHash); + } + + @Override + public Optional findByOriginal(Url originalUrl) { + return storage.values().stream() + .filter(urlLink -> Objects.equals(urlLink.getOriginalUrl(), originalUrl)) + .findAny(); + } + + @Override + public Optional findByUrlHash(UrlHash findUrlHash) { + return Optional.ofNullable(hashedUrlIndex.get(findUrlHash)); + } + + private long generateNewId() { + return idGenerator.getAndIncrement(); + } + + private void validateUrlLink(UrlLink urlLink) { + if (urlLink == null) { + throw new IllegalArgumentException("UrlLink가 null일 경우 저장할 수 없습니다."); + } + if (urlLink.getUrlHash() == null || urlLink.getOriginalUrl() == null) { + throw new IllegalArgumentException("UrlLink의 필드는 null일 경우 저장할 수 없습니다."); + } + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/DefaultUrlShortenerService.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/DefaultUrlShortenerService.java new file mode 100644 index 00000000..d7f31766 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/DefaultUrlShortenerService.java @@ -0,0 +1,55 @@ +package com.prgrms.wonu606.shorturl.service; + +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlLink; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateParam; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateResult; +import com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.UniqueUrlHashCreator; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class DefaultUrlShortenerService implements UrlShortenerService { + + private final UrlLinkRepository urlLinkRepository; + private final UniqueUrlHashCreator uniqueUrlHashCreator; + + public DefaultUrlShortenerService(UrlLinkRepository urlLinkRepository, + UniqueUrlHashCreator uniqueUrlHashCreator) { + this.urlLinkRepository = urlLinkRepository; + this.uniqueUrlHashCreator = uniqueUrlHashCreator; + } + + @Override + public ShortenUrlCreateResult findOrCreateShortenUrlHash(ShortenUrlCreateParam param) { + Url originalUrl = new Url(param.originalUrl()); + + Optional urlLinkOptional = urlLinkRepository.findByOriginal(originalUrl); + if (urlLinkOptional.isPresent()) { + UrlLink foundUrlLink = urlLinkOptional.get(); + return new ShortenUrlCreateResult(foundUrlLink.getUrlHash().value(), false); + } + + return createUrlHash(originalUrl); + } + + @Override + public String getOriginalUrlByShortUrl(String shortUrl) { + UrlHash findUrlHash = new UrlHash(shortUrl); + + return urlLinkRepository.findByUrlHash(findUrlHash) + .map(UrlLink::getOriginalUrl) + .map(Url::value) + .orElseThrow(() -> new UrlNotFoundException("존재 하지 않는 Short URL입니다. Current Short Url: " + shortUrl)); + } + + private ShortenUrlCreateResult createUrlHash(Url originalUrl) { + UrlHash uniqueUrlHashHash = uniqueUrlHashCreator.create(originalUrl); + + UrlLink createdUrlLink = new UrlLink(originalUrl, uniqueUrlHashHash); + urlLinkRepository.save(createdUrlLink); + + return new ShortenUrlCreateResult(createdUrlLink.getUrlHash().value(), true); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlLinkRepository.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlLinkRepository.java new file mode 100644 index 00000000..5ba87428 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlLinkRepository.java @@ -0,0 +1,18 @@ +package com.prgrms.wonu606.shorturl.service; + +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.domain.UrlLink; +import java.util.Optional; +import java.util.stream.Stream; + +public interface UrlLinkRepository { + + Long save(UrlLink urlLink); + + boolean existByUrlHash(UrlHash urlHash); + + Optional findByOriginal(Url originalUrl); + + Optional findByUrlHash(UrlHash findUrlHash); +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlNotFoundException.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlNotFoundException.java new file mode 100644 index 00000000..bdc697be --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlNotFoundException.java @@ -0,0 +1,8 @@ +package com.prgrms.wonu606.shorturl.service; + +public class UrlNotFoundException extends RuntimeException { + + public UrlNotFoundException(String message) { + super(message); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlShortenerService.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlShortenerService.java new file mode 100644 index 00000000..a27d9c4a --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/UrlShortenerService.java @@ -0,0 +1,11 @@ +package com.prgrms.wonu606.shorturl.service; + +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateParam; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateResult; + +public interface UrlShortenerService { + + ShortenUrlCreateResult findOrCreateShortenUrlHash(ShortenUrlCreateParam param); + + String getOriginalUrlByShortUrl(String shortUrl); +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/dto/ShortenUrlCreateParam.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/dto/ShortenUrlCreateParam.java new file mode 100644 index 00000000..726445a3 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/dto/ShortenUrlCreateParam.java @@ -0,0 +1,6 @@ +package com.prgrms.wonu606.shorturl.service.dto; + +public record ShortenUrlCreateParam( + String originalUrl) { + +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/dto/ShortenUrlCreateResult.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/dto/ShortenUrlCreateResult.java new file mode 100644 index 00000000..eb8b98c7 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/dto/ShortenUrlCreateResult.java @@ -0,0 +1,5 @@ +package com.prgrms.wonu606.shorturl.service.dto; + +public record ShortenUrlCreateResult(String hashedShortUrl, boolean isNew) { + +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueHashCreationException.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueHashCreationException.java new file mode 100644 index 00000000..034b355e --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueHashCreationException.java @@ -0,0 +1,8 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator; + +public class UniqueHashCreationException extends RuntimeException { + + public UniqueHashCreationException(String message) { + super(message); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueUrlHashCreator.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueUrlHashCreator.java new file mode 100644 index 00000000..03cc3c40 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueUrlHashCreator.java @@ -0,0 +1,29 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator; + +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator.ChunkWeight; +import com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator.UrlHashCreator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UniqueUrlHashCreator { + + private static final int MAX_GENERATION_ATTEMPTS = 100; + + private final UrlHashCreator urlHashCreator; + private final UrlHashExistenceChecker urlHashExistenceChecker; + + public UrlHash create(Url originalUrl) { + for (int attemptCount = 1; attemptCount <= MAX_GENERATION_ATTEMPTS; attemptCount++) { + UrlHash urlHash = urlHashCreator.create(originalUrl, new ChunkWeight(attemptCount)); + if (!urlHashExistenceChecker.exists(urlHash)) { + return urlHash; + } + } + + throw new UniqueHashCreationException("유니크한 URL 해시를 생성하는 데 실패했습니다."); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UrlHashExistenceChecker.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UrlHashExistenceChecker.java new file mode 100644 index 00000000..f5a6ab86 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UrlHashExistenceChecker.java @@ -0,0 +1,17 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator; + +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.service.UrlLinkRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UrlHashExistenceChecker { + + private final UrlLinkRepository urlLinkRepository; + + public boolean exists(UrlHash urlHash) { + return urlLinkRepository.existByUrlHash(urlHash); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkBasedUrlHashCreator.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkBasedUrlHashCreator.java new file mode 100644 index 00000000..11200a48 --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkBasedUrlHashCreator.java @@ -0,0 +1,41 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator; + +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import org.springframework.stereotype.Component; + +/** + * 주어진 URL을 청크로 나눈 후, 각 청크를 기반으로 해시 문자를 생성합니다. + * 이때, 각 청크의 ASCII 값의 합을 계산하여 알파벳 문자열에서 해당 인덱스의 문자를 찾아 해시를 형성합니다. + *

+ * 이 방법은 URL의 길이와 상관 없이 일정한 길이의 해시를 생성할 수 있도록 합니다. + */ +@Component +public class ChunkBasedUrlHashCreator implements UrlHashCreator { + + private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final int HASH_SIZE = 8; + + @Override + public UrlHash create(Url url, ChunkWeight weight) { + String urlString = url.value(); + + int chunkSize = Math.max(urlString.length() / HASH_SIZE, 1); + StringBuilder shortUrlBuilder = new StringBuilder(HASH_SIZE); + + for (int i = 0; i < HASH_SIZE; i++) { + String chunk = urlString.substring(i * chunkSize, Math.min((i + 1) * chunkSize, urlString.length())); + shortUrlBuilder.append(getCharForChunk(chunk, weight)); + } + + return new UrlHash(shortUrlBuilder.toString()); + } + + private char getCharForChunk(String chunk, ChunkWeight weight) { + int sumAscii = chunk.chars() + .map(c -> c * weight.value()) + .sum(); + + return ALPHABET.charAt(sumAscii % ALPHABET.length()); + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkWeight.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkWeight.java new file mode 100644 index 00000000..41eb290d --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkWeight.java @@ -0,0 +1,14 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator; + +public record ChunkWeight(int value) { + + public ChunkWeight { + validate(value); + } + + private void validate(int value) { + if (value <= 0) { + throw new IllegalArgumentException("Weight 값은 양수여야 합니다. Current Weight: " + value); + } + } +} diff --git a/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/UrlHashCreator.java b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/UrlHashCreator.java new file mode 100644 index 00000000..9d50852d --- /dev/null +++ b/short-url/src/main/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/UrlHashCreator.java @@ -0,0 +1,9 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator; + +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.domain.Url; + +public interface UrlHashCreator { + + UrlHash create(Url url, ChunkWeight weight); +} diff --git a/short-url/src/main/resources/application.yaml b/short-url/src/main/resources/application.yaml new file mode 100644 index 00000000..c5c412dd --- /dev/null +++ b/short-url/src/main/resources/application.yaml @@ -0,0 +1,2 @@ +url-shortener: + base-url: "http://localhost:8080/" diff --git a/short-url/src/main/resources/templates/index.html b/short-url/src/main/resources/templates/index.html new file mode 100644 index 00000000..1d06f8a3 --- /dev/null +++ b/short-url/src/main/resources/templates/index.html @@ -0,0 +1,70 @@ + + + + + + + URL Shortener + + +

+

URL Shortener

+
+
+ + +
+ +
+ +
+ + + + diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/ShortUrlApplicationTests.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/ShortUrlApplicationTests.java new file mode 100644 index 00000000..73dceae3 --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/ShortUrlApplicationTests.java @@ -0,0 +1,13 @@ +package com.prgrms.wonu606.shorturl; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ShortUrlApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/controller/ShortUrlApiControllerTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/controller/ShortUrlApiControllerTest.java new file mode 100644 index 00000000..201aa095 --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/controller/ShortUrlApiControllerTest.java @@ -0,0 +1,48 @@ +package com.prgrms.wonu606.shorturl.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateRequest; +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateResponse; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class ShortUrlApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void whenShortUrlExists_thenRedirect() throws Exception { + // Given + String originalUrl = "https://naver.com"; + ShortenUrlCreateRequest request = new ShortenUrlCreateRequest(originalUrl); + + // When & Then + String responseJson = mockMvc.perform(post("/api/shorten-url") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andReturn().getResponse().getContentAsString(); + + ShortenUrlCreateResponse response = objectMapper.readValue(responseJson, ShortenUrlCreateResponse.class); + String shortUrl = response.shortenUrl().substring(response.shortenUrl().lastIndexOf("/")); + + mockMvc.perform(get(shortUrl)) + .andExpect(status().isFound()) + .andExpect(header().string("Location", originalUrl)); + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerApiControllerTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerApiControllerTest.java new file mode 100644 index 00000000..9dfdd41f --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/controller/UrlShortenerApiControllerTest.java @@ -0,0 +1,95 @@ +package com.prgrms.wonu606.shorturl.controller; + +import static org.hamcrest.text.MatchesPattern.matchesPattern; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.prgrms.wonu606.shorturl.controller.dto.ShortenUrlCreateRequest; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; + +@SpringBootTest +@AutoConfigureMockMvc +class UrlShortenerApiControllerTest { + + private static final String API_URL = "/api/shorten-url"; + private static final String SHORTEN_URL_PATTERN = "^http(s*):\\/\\/.+\\/\\w{1,8}$"; + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Nested + class FindOrCreateShortenedUrlMethodTests { + + @ParameterizedTest + @MethodSource("provideValidUrls") + void whenUrlIsValid_thenShouldReturnShortenedUrl(ShortenUrlCreateRequest request) throws Exception { + // When & Then + mockMvc.perform(performPostRequest(request)) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.shortenUrl").value(matchesPattern(SHORTEN_URL_PATTERN))); + } + + @ParameterizedTest + @MethodSource("provideInvalidUrls") + void whenUrlIsInvalid_thenShouldReturnBadRequest(ShortenUrlCreateRequest request) throws Exception { + // When & Then + mockMvc.perform(performPostRequest(request)) + .andExpect(status().isBadRequest()); + } + + @ParameterizedTest + @MethodSource("provideValidUrls") + void whenUrlExists_thenHttpStatusIsOk(ShortenUrlCreateRequest request) throws Exception { + // When + mockMvc.perform(performPostRequest(request)); + + // When & Then + mockMvc.perform(performPostRequest(request)) + .andExpect(status().isOk()); + } + + private RequestBuilder performPostRequest(ShortenUrlCreateRequest request) throws Exception { + String requestBody = objectMapper.writeValueAsString(request); + + return post(API_URL) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody); + } + + + static Stream provideValidUrls() { + return Stream.of( + Arguments.of(new ShortenUrlCreateRequest("https://url.kr")), + Arguments.of(new ShortenUrlCreateRequest("https://www.stackoverflow.com")), + Arguments.of(new ShortenUrlCreateRequest("https://comic.naver.com/index")), + Arguments.of(new ShortenUrlCreateRequest("https://www.youtube.com/watch?v=Yc56NpYW1DM")), + Arguments.of(new ShortenUrlCreateRequest("https://www.youtube.com/@devbadak")) + ); + } + + static Stream provideInvalidUrls() { + String overLengthUrl = "https://".concat("a".repeat(2000)); + return Stream.of( + Arguments.of(new ShortenUrlCreateRequest(null)), + Arguments.of(new ShortenUrlCreateRequest(overLengthUrl)) + ); + } + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlHashTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlHashTest.java new file mode 100644 index 00000000..cb6df8e2 --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlHashTest.java @@ -0,0 +1,40 @@ +package com.prgrms.wonu606.shorturl.domain; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class UrlHashTest { + + @ParameterizedTest + @ValueSource(strings = { + "12345678", + "abcd1234", + "a1b2c3d4", + "1a2b3c4d", + "abcd", + "A", + "A123" + } + ) + void givenValidHashedUrl_thenCreatingHashedShortUrl(String validHash) { + assertThatCode(() -> new UrlHash(validHash)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + "123456789", + "abcdefghi", + "a1b2c3d4e", + "1a2b3c4d5" + }) + void givenInvalidHashedUrl_thenThrowException(String invalidHash) { + assertThatThrownBy(() -> new UrlHash(invalidHash)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlLinkTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlLinkTest.java new file mode 100644 index 00000000..591ee4c5 --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlLinkTest.java @@ -0,0 +1,96 @@ +package com.prgrms.wonu606.shorturl.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.MethodSource; + +class UrlLinkTest { + + @Nested + class InitializeIdMethodTests { + + @ParameterizedTest + @MethodSource("provideUrlLinkAndValidIdArguments") + void whenCalledFirstTime_thenSuccess(UrlLink urlLink, Long newId) { + // When + urlLink.initializeId(newId); + + // Then + assertThat(urlLink.getId()).isEqualTo(newId); + } + + @ParameterizedTest + @MethodSource("provideUrlLinkAndValidIdArguments") + void whenCalledTwice_thenThrowException(UrlLink urlLink, Long newId) { + // When + urlLink.initializeId(newId); + + // When & Then + assertThatThrownBy(() -> urlLink.initializeId(newId)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("ID가 이미 할당되어 있습니다. Current ID: " + newId); + } + + @ParameterizedTest + @MethodSource("provideUrlLinkAndInvalidIdArguments") + void givenInvalidId_throwException(UrlLink urlLink, Long invalidId) { + // When & Then + assertThatThrownBy(() -> urlLink.initializeId(invalidId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("ID는 0보다 크고 null이 아니어야 합니다. Input ID: " + invalidId); + } + + static Stream provideUrlLinkAndValidIdArguments() throws Exception { + return createArgumentsWithUrlLinks(Stream.of(1L)); + } + + static Stream provideUrlLinkAndInvalidIdArguments() throws Exception { + return createArgumentsWithUrlLinks(Stream.of(null, -1L, 0L)); + } + + static Stream createArgumentsWithUrlLinks(Stream ids) throws Exception { + UrlLinkArgumentsProvider urlLinkArgumentsProvider = new UrlLinkArgumentsProvider(); + List urlLinkList = urlLinkArgumentsProvider.provideArguments(null) + .map(arguments -> (UrlLink) arguments.get()[0]) + .toList(); + + List idList = ids.toList(); + + List argumentsList = new ArrayList<>(); + for (int i = 0; i < Math.min(urlLinkList.size(), idList.size()); i++) { + argumentsList.add(Arguments.of(urlLinkList.get(i), idList.get(i))); + } + return argumentsList.stream(); + } + } + + static class UrlLinkArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of( + Arguments.of(new UrlLink( + new Url("https://example.com"), + new UrlHash("12345678") + )), + Arguments.of(new UrlLink( + new Url("https://www.example.com"), + new UrlHash("123abcd") + )), + Arguments.of(new UrlLink( + new Url("https://www.example.com/index"), + new UrlHash("ABC12ab") + )) + ); + } + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlTest.java new file mode 100644 index 00000000..fe823fa8 --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/domain/UrlTest.java @@ -0,0 +1,60 @@ +package com.prgrms.wonu606.shorturl.domain; + + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class UrlTest { + + @ParameterizedTest + @ValueSource(strings = { + "http://example.com", + "https://example.com", + "http://www.example.com", + "https://www.example.com", + "http://example.com/path", + "https://example.com/path", + "http://example.com/path#fragment", + "ftp://example.com", + "ftp://user:password@example.com", + "ftp://user@example.com", + "ftp://example.com" + }) + void givenValidUrl_thenCreatingUrl(String validUrl) { + // When & Then + assertThatCode(() -> new Url(validUrl)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = { + "invalidUrl", + "http:/example.com", + "https:/example.com", + "http//example.com", + "https//example.com", + "httpx://www.naver.com", + }) + void givenInvalidUrl_thenThrowException(String invalidUrl) { + // When & Then + assertThatThrownBy(() -> new Url(invalidUrl)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("잘못된 URL 주소입니다. Current URL: " + invalidUrl); + } + + @Test + void givenLongUrl_throwException() { + // Given + String longUrl = "https://example.com/%s".formatted("a".repeat(2_000)); + + assertThatThrownBy(() -> new Url(longUrl)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("URL의 길이는 %d를 넘어갈 수 없습니다 Current Length: %d", Url.URL_MAX_LENGTH, longUrl.length()); + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/DefaultUrlShortenerServiceTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/DefaultUrlShortenerServiceTest.java new file mode 100644 index 00000000..651c001e --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/DefaultUrlShortenerServiceTest.java @@ -0,0 +1,114 @@ +package com.prgrms.wonu606.shorturl.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateParam; +import com.prgrms.wonu606.shorturl.service.dto.ShortenUrlCreateResult; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DefaultUrlShortenerServiceTest { + + @Autowired + private DefaultUrlShortenerService defaultUrlShortenerService; + + @Autowired + private UrlLinkRepository urlLinkRepository; + + @Nested + class FindOrCreateShortenUrlHashMethodTests { + + @ParameterizedTest + @MethodSource("provideValidUrls") + void givenValidParam_thenCreatingUrlLink(ShortenUrlCreateParam param) { + // When + ShortenUrlCreateResult result = defaultUrlShortenerService.findOrCreateShortenUrlHash(param); + UrlHash createdUrlHash = new UrlHash(result.hashedShortUrl()); + + // Then + assertThat(urlLinkRepository.existByUrlHash(createdUrlHash)).isTrue(); + } + + @ParameterizedTest + @MethodSource("provideValidUrls") + void whenUrlExists_thenReturnExistingHash(ShortenUrlCreateParam param) { + // When + defaultUrlShortenerService.findOrCreateShortenUrlHash(param); + ShortenUrlCreateResult result = defaultUrlShortenerService.findOrCreateShortenUrlHash(param); + + // Then + assertThat(result.isNew()).isFalse(); + } + + + static Stream provideValidUrls() throws Exception { + ValidUrlStringArgumentsProvider validUrlStringArgumentsProvider = new ValidUrlStringArgumentsProvider(); + List validUrlStringList = validUrlStringArgumentsProvider.provideArguments(null) + .map(arguments -> arguments.get()[0].toString()) + .toList(); + + List argumentsList = new ArrayList<>(); + for (String validUrlString : validUrlStringList) { + argumentsList.add(Arguments.of(new ShortenUrlCreateParam(validUrlString))); + } + return argumentsList.stream(); + } + } + + @Nested + class GetOriginalUrlByShortUrlMethodTests { + + @ParameterizedTest + @ArgumentsSource(ValidUrlStringArgumentsProvider.class) + void whenShortUrlExists_thenReturnOriginalUrl(String originalUrl) { + // When + ShortenUrlCreateResult shortenUrlCreateResult = + defaultUrlShortenerService.findOrCreateShortenUrlHash(new ShortenUrlCreateParam(originalUrl)); + + String actualOriginalUrl = + defaultUrlShortenerService.getOriginalUrlByShortUrl(shortenUrlCreateResult.hashedShortUrl()); + + // Then + assertThat(actualOriginalUrl).isEqualTo(originalUrl); + } + + @Test + void whenShortUrlDoesNotExist_thenThrowException() { + // Given + String nonExistentShortUrl = "GSSORWLL"; + // When & Then + assertThatThrownBy(() -> + defaultUrlShortenerService.getOriginalUrlByShortUrl(nonExistentShortUrl)) + .isInstanceOf(UrlNotFoundException.class) + .hasMessage("존재 하지 않는 Short URL입니다. Current Short Url: " + nonExistentShortUrl); + } + } + + static class ValidUrlStringArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of( + Arguments.of("https://url.kr"), + Arguments.of("https://www.stackoverflow.com"), + Arguments.of("https://comic.naver.com/index"), + Arguments.of("https://www.youtube.com/watch?v=Yc56NpYW1DM"), + Arguments.of("https://www.youtube.com/@devbadak") + ); + } + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueUrlHashCreatorTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueUrlHashCreatorTest.java new file mode 100644 index 00000000..6c9d811d --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/UniqueUrlHashCreatorTest.java @@ -0,0 +1,53 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +@SpringBootTest +class UniqueUrlHashCreatorTest { + + @Autowired + private UniqueUrlHashCreator uniqueUrlHashCreator; + + @MockBean + private UrlHashExistenceChecker urlHashExistenceChecker; + + @Nested + class CreateMethodTests { + + @Test + void whenSuccess_thenCreatingUniqUrlHash() { + // Given + given(urlHashExistenceChecker.exists(any())).willReturn(false); + Url url = new Url("https://example.com"); + + // When + UrlHash urlHash = uniqueUrlHashCreator.create(url); + + // Then + assertThat(urlHash).isNotNull(); + } + + @Test + void whenFailure_thenThrowException() { + // Given + given(urlHashExistenceChecker.exists(any())).willReturn(true); + Url url = new Url("https://example.com"); + + // When & Then + assertThatThrownBy(() -> uniqueUrlHashCreator.create(url)) + .isInstanceOf(UniqueHashCreationException.class) + .hasMessage("유니크한 URL 해시를 생성하는 데 실패했습니다."); + } + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkBasedUrlHashCreatorTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkBasedUrlHashCreatorTest.java new file mode 100644 index 00000000..786bb937 --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkBasedUrlHashCreatorTest.java @@ -0,0 +1,32 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.prgrms.wonu606.shorturl.domain.Url; +import com.prgrms.wonu606.shorturl.domain.UrlHash; +import io.micrometer.common.util.StringUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ChunkBasedUrlHashCreatorTest { + + private ChunkBasedUrlHashCreator chunkBasedUrlHashCreator; + + @BeforeEach + void setup() { + chunkBasedUrlHashCreator = new ChunkBasedUrlHashCreator(); + } + + @Test + void create_thenReturnNonBlank() { + // Given + Url url = new Url("https://x.com"); + ChunkWeight chunkWeight = new ChunkWeight(1); + + // When + UrlHash urlHash = chunkBasedUrlHashCreator.create(url, chunkWeight); + + // Then + assertThat(StringUtils.isNotBlank(urlHash.value())).isTrue(); + } +} diff --git a/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkWeightTest.java b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkWeightTest.java new file mode 100644 index 00000000..51acf81b --- /dev/null +++ b/short-url/src/test/java/com/prgrms/wonu606/shorturl/service/shorturlhashgenerator/urlhashcreator/ChunkWeightTest.java @@ -0,0 +1,31 @@ +package com.prgrms.wonu606.shorturl.service.shorturlhashgenerator.urlhashcreator; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ChunkWeightTest { + + @ParameterizedTest + @ValueSource(ints = { + 1, 2, 3, 4, 5 + }) + void givenPositiveWeight_thenDoesNotThrowException(int positiveWeight) { + // When & Then + assertThatCode(() -> new ChunkWeight(positiveWeight)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(ints = { + 0, -1, -2, -3, -4, -5 + }) + void givenNonPositiveWeight_thenThrowException(int nonPositiveWeight) { + // When & Then + assertThatThrownBy(() -> new ChunkWeight(nonPositiveWeight)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Weight 값은 양수여야 합니다. Current Weight: " + nonPositiveWeight); + } +} diff --git a/short-url/src/test/resources/application.properties b/short-url/src/test/resources/application.properties new file mode 100644 index 00000000..ce2df184 --- /dev/null +++ b/short-url/src/test/resources/application.properties @@ -0,0 +1 @@ +url-shortener.base-url=http://localhost:8080/