From 9095f00a686255f2322d7c7b698e9dc067f054c7 Mon Sep 17 00:00:00 2001 From: Buffden Date: Sat, 14 Mar 2026 17:45:21 -0500 Subject: [PATCH 1/5] gitignore update --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 7430190..c1222e2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ target/ build/ dist/ out/ +.gradle/ + +# Spring scaffold helper docs +tinyurl/HELP.md # Dependency directories node_modules/ From af0976a1648c693c00256539354233b3454bf3b5 Mon Sep 17 00:00:00 2001 From: Buffden Date: Tue, 17 Mar 2026 01:43:58 -0500 Subject: [PATCH 2/5] Set up Gradle build and app configuration for Phase 2 --- tinyurl/build.gradle.kts | 37 +++++++++++++++++++++ tinyurl/settings.gradle.kts | 1 + tinyurl/src/main/resources/application.yaml | 23 +++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 tinyurl/build.gradle.kts create mode 100644 tinyurl/settings.gradle.kts create mode 100644 tinyurl/src/main/resources/application.yaml diff --git a/tinyurl/build.gradle.kts b/tinyurl/build.gradle.kts new file mode 100644 index 0000000..1aa7a0b --- /dev/null +++ b/tinyurl/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + java + id("org.springframework.boot") version "3.5.0" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "com.tinyurl" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8") + implementation("org.flywaydb:flyway-core") + runtimeOnly("org.flywaydb:flyway-database-postgresql") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("com.h2database:h2") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/tinyurl/settings.gradle.kts b/tinyurl/settings.gradle.kts new file mode 100644 index 0000000..55c293b --- /dev/null +++ b/tinyurl/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "tinyurl" diff --git a/tinyurl/src/main/resources/application.yaml b/tinyurl/src/main/resources/application.yaml new file mode 100644 index 0000000..1706ab5 --- /dev/null +++ b/tinyurl/src/main/resources/application.yaml @@ -0,0 +1,23 @@ +spring: + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/tinyurl} + username: ${DB_USERNAME:tinyurl} + password: ${DB_PASSWORD:tinyurl} + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + flyway: + enabled: true + +management: + endpoints: + web: + exposure: + include: health,info + +tinyurl: + base-url: ${TINYURL_BASE_URL:http://localhost} + default-expiry-days: ${TINYURL_DEFAULT_EXPIRY_DAYS:180} + short-code-min-length: ${TINYURL_SHORT_CODE_MIN_LENGTH:6} + From d8815f51638eab84562b37a6cef7935b292f8147 Mon Sep 17 00:00:00 2001 From: Buffden Date: Tue, 17 Mar 2026 01:43:58 -0500 Subject: [PATCH 3/5] Implement Phase 2 URL shortening core flow and persistence --- .../java/com/tinyurl/TinyurlApplication.java | 14 +++ .../com/tinyurl/config/AppProperties.java | 11 ++ .../controller/GlobalExceptionHandler.java | 79 +++++++++++++ .../tinyurl/controller/HealthController.java | 14 +++ .../controller/RedirectController.java | 42 +++++++ .../com/tinyurl/controller/UrlController.java | 42 +++++++ .../com/tinyurl/dto/CreateUrlRequest.java | 17 +++ .../com/tinyurl/dto/CreateUrlResponse.java | 11 ++ .../java/com/tinyurl/dto/ErrorResponse.java | 7 ++ .../main/java/com/tinyurl/dto/UrlMapping.java | 12 ++ .../com/tinyurl/encoding/Base62Encoder.java | 6 + .../tinyurl/encoding/Base62EncoderImpl.java | 54 +++++++++ .../com/tinyurl/exception/GoneException.java | 7 ++ .../tinyurl/exception/NotFoundException.java | 7 ++ .../tinyurl/repository/JpaUrlRepository.java | 40 +++++++ .../tinyurl/repository/UrlMappingEntity.java | 74 ++++++++++++ .../com/tinyurl/repository/UrlRepository.java | 9 ++ .../java/com/tinyurl/service/UrlService.java | 10 ++ .../com/tinyurl/service/UrlServiceImpl.java | 105 ++++++++++++++++++ .../db/migration/V1__init_schema.sql | 12 ++ 20 files changed, 573 insertions(+) create mode 100644 tinyurl/src/main/java/com/tinyurl/TinyurlApplication.java create mode 100644 tinyurl/src/main/java/com/tinyurl/config/AppProperties.java create mode 100644 tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java create mode 100644 tinyurl/src/main/java/com/tinyurl/controller/HealthController.java create mode 100644 tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java create mode 100644 tinyurl/src/main/java/com/tinyurl/controller/UrlController.java create mode 100644 tinyurl/src/main/java/com/tinyurl/dto/CreateUrlRequest.java create mode 100644 tinyurl/src/main/java/com/tinyurl/dto/CreateUrlResponse.java create mode 100644 tinyurl/src/main/java/com/tinyurl/dto/ErrorResponse.java create mode 100644 tinyurl/src/main/java/com/tinyurl/dto/UrlMapping.java create mode 100644 tinyurl/src/main/java/com/tinyurl/encoding/Base62Encoder.java create mode 100644 tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java create mode 100644 tinyurl/src/main/java/com/tinyurl/exception/GoneException.java create mode 100644 tinyurl/src/main/java/com/tinyurl/exception/NotFoundException.java create mode 100644 tinyurl/src/main/java/com/tinyurl/repository/JpaUrlRepository.java create mode 100644 tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java create mode 100644 tinyurl/src/main/java/com/tinyurl/repository/UrlRepository.java create mode 100644 tinyurl/src/main/java/com/tinyurl/service/UrlService.java create mode 100644 tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java create mode 100644 tinyurl/src/main/resources/db/migration/V1__init_schema.sql diff --git a/tinyurl/src/main/java/com/tinyurl/TinyurlApplication.java b/tinyurl/src/main/java/com/tinyurl/TinyurlApplication.java new file mode 100644 index 0000000..ee39e6c --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/TinyurlApplication.java @@ -0,0 +1,14 @@ +package com.tinyurl; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class TinyurlApplication { + + public static void main(String[] args) { + SpringApplication.run(TinyurlApplication.class, args); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java b/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java new file mode 100644 index 0000000..8dcb165 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/config/AppProperties.java @@ -0,0 +1,11 @@ +package com.tinyurl.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "tinyurl") +public record AppProperties( + String baseUrl, + Integer defaultExpiryDays, + Integer shortCodeMinLength +) { +} diff --git a/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java b/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..075d301 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/controller/GlobalExceptionHandler.java @@ -0,0 +1,79 @@ +package com.tinyurl.controller; + +import com.tinyurl.dto.ErrorResponse; +import com.tinyurl.exception.GoneException; +import com.tinyurl.exception.NotFoundException; +import jakarta.validation.ConstraintViolationException; +import jakarta.persistence.PersistenceException; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String code = "INVALID_REQUEST"; + FieldError fieldError = ex.getBindingResult().getFieldError(); + if (fieldError != null && fieldError.getDefaultMessage() != null) { + code = fieldError.getDefaultMessage(); + } + return ResponseEntity.badRequest().body(new ErrorResponse(code, messageForCode(code))); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("INVALID_URL", messageForCode("INVALID_URL"))); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + String code = ex.getMessage() == null ? "INVALID_REQUEST" : ex.getMessage(); + HttpStatus status = "INVALID_EXPIRY".equals(code) || "INVALID_URL".equals(code) + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(status).body(new ErrorResponse(code, messageForCode(code))); + } + + @ExceptionHandler({DataAccessException.class, PersistenceException.class}) + public ResponseEntity handleServiceUnavailable(Exception ex) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Retry-After", "30"); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .headers(headers) + .body(new ErrorResponse("SERVICE_UNAVAILABLE", "The service is temporarily unavailable. Please try again.")); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse("NOT_FOUND", ex.getMessage())); + } + + @ExceptionHandler(GoneException.class) + public ResponseEntity handleGone(GoneException ex) { + return ResponseEntity.status(HttpStatus.GONE) + .body(new ErrorResponse("GONE", ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred. Please try again.")); + } + + private String messageForCode(String code) { + return switch (code) { + case "INVALID_URL" -> "URL must be a valid HTTP or HTTPS address (max 2048 characters)."; + case "INVALID_EXPIRY" -> "Expiry must be a positive integer not greater than 3650 days."; + default -> "Request validation failed."; + }; + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/controller/HealthController.java b/tinyurl/src/main/java/com/tinyurl/controller/HealthController.java new file mode 100644 index 0000000..c5fceca --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/controller/HealthController.java @@ -0,0 +1,14 @@ +package com.tinyurl.controller; + +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP"); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java b/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java new file mode 100644 index 0000000..0439be2 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/controller/RedirectController.java @@ -0,0 +1,42 @@ +package com.tinyurl.controller; + +import com.tinyurl.dto.UrlMapping; +import com.tinyurl.exception.NotFoundException; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import com.tinyurl.service.UrlService; +import java.net.URI; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Validated +public class RedirectController { + + private final UrlService urlService; + + public RedirectController(UrlService urlService) { + this.urlService = urlService; + } + + @GetMapping("/{shortCode}") + public ResponseEntity redirect( + @PathVariable + @Size(min = 6, max = 8, message = "INVALID_URL") + @Pattern(regexp = "^[0-9A-Za-z]+$", message = "INVALID_URL") + String shortCode + ) { + UrlMapping mapping = urlService.resolveCode(shortCode) + .orElseThrow(() -> new NotFoundException("No URL found for this short code.")); + + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create(mapping.originalUrl())); + HttpStatus status = mapping.explicitExpiry() ? HttpStatus.FOUND : HttpStatus.MOVED_PERMANENTLY; + return new ResponseEntity<>(headers, status); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java b/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java new file mode 100644 index 0000000..c1cf91d --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/controller/UrlController.java @@ -0,0 +1,42 @@ +package com.tinyurl.controller; + +import com.tinyurl.config.AppProperties; +import com.tinyurl.dto.CreateUrlRequest; +import com.tinyurl.dto.CreateUrlResponse; +import com.tinyurl.dto.UrlMapping; +import com.tinyurl.service.UrlService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +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/urls") +public class UrlController { + + private final UrlService urlService; + private final AppProperties appProperties; + + public UrlController(UrlService urlService, AppProperties appProperties) { + this.urlService = urlService; + this.appProperties = appProperties; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateUrlRequest request) { + UrlMapping created = urlService.shortenUrl(request); + String baseUrl = appProperties.baseUrl().endsWith("/") + ? appProperties.baseUrl().substring(0, appProperties.baseUrl().length() - 1) + : appProperties.baseUrl(); + CreateUrlResponse response = new CreateUrlResponse( + baseUrl + "/" + created.shortCode(), + created.shortCode(), + created.originalUrl(), + created.expiresAt() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/dto/CreateUrlRequest.java b/tinyurl/src/main/java/com/tinyurl/dto/CreateUrlRequest.java new file mode 100644 index 0000000..e19a947 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/dto/CreateUrlRequest.java @@ -0,0 +1,17 @@ +package com.tinyurl.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record CreateUrlRequest( + @NotBlank(message = "INVALID_URL") + @Size(max = 2048, message = "INVALID_URL") + String url, + + @Positive(message = "INVALID_EXPIRY") + @Max(value = 3650, message = "INVALID_EXPIRY") + Integer expiresInDays +) { +} diff --git a/tinyurl/src/main/java/com/tinyurl/dto/CreateUrlResponse.java b/tinyurl/src/main/java/com/tinyurl/dto/CreateUrlResponse.java new file mode 100644 index 0000000..bc582db --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/dto/CreateUrlResponse.java @@ -0,0 +1,11 @@ +package com.tinyurl.dto; + +import java.time.OffsetDateTime; + +public record CreateUrlResponse( + String shortUrl, + String shortCode, + String originalUrl, + OffsetDateTime expiresAt +) { +} diff --git a/tinyurl/src/main/java/com/tinyurl/dto/ErrorResponse.java b/tinyurl/src/main/java/com/tinyurl/dto/ErrorResponse.java new file mode 100644 index 0000000..100473a --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.tinyurl.dto; + +public record ErrorResponse( + String code, + String message +) { +} diff --git a/tinyurl/src/main/java/com/tinyurl/dto/UrlMapping.java b/tinyurl/src/main/java/com/tinyurl/dto/UrlMapping.java new file mode 100644 index 0000000..1b44f70 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/dto/UrlMapping.java @@ -0,0 +1,12 @@ +package com.tinyurl.dto; + +import java.time.OffsetDateTime; + +public record UrlMapping( + Long id, + String shortCode, + String originalUrl, + OffsetDateTime expiresAt, + boolean explicitExpiry +) { +} diff --git a/tinyurl/src/main/java/com/tinyurl/encoding/Base62Encoder.java b/tinyurl/src/main/java/com/tinyurl/encoding/Base62Encoder.java new file mode 100644 index 0000000..f82324d --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/encoding/Base62Encoder.java @@ -0,0 +1,6 @@ +package com.tinyurl.encoding; + +public interface Base62Encoder { + String encode(long id); + long decode(String code); +} diff --git a/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java b/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java new file mode 100644 index 0000000..b92528d --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java @@ -0,0 +1,54 @@ +package com.tinyurl.encoding; + +import org.springframework.stereotype.Component; + +@Component +public class Base62EncoderImpl implements Base62Encoder { + + private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static final int BASE = CHARSET.length(); + private static final int MIN_LENGTH = 6; + + @Override + public String encode(long id) { + if (id < 0) { + throw new IllegalArgumentException("id must be non-negative"); + } + + if (id == 0) { + return "0".repeat(MIN_LENGTH); + } + + StringBuilder encoded = new StringBuilder(); + long value = id; + + while (value > 0) { + int index = (int) (value % BASE); + encoded.append(CHARSET.charAt(index)); + value /= BASE; + } + + while (encoded.length() < MIN_LENGTH) { + encoded.append('0'); + } + + return encoded.reverse().toString(); + } + + @Override + public long decode(String code) { + if (code == null || code.isBlank()) { + throw new IllegalArgumentException("code must not be blank"); + } + + long result = 0; + for (int i = 0; i < code.length(); i++) { + int charIndex = CHARSET.indexOf(code.charAt(i)); + if (charIndex < 0) { + throw new IllegalArgumentException("invalid base62 code"); + } + result = (result * BASE) + charIndex; + } + return result; + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/exception/GoneException.java b/tinyurl/src/main/java/com/tinyurl/exception/GoneException.java new file mode 100644 index 0000000..9a378bf --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/exception/GoneException.java @@ -0,0 +1,7 @@ +package com.tinyurl.exception; + +public class GoneException extends RuntimeException { + public GoneException(String message) { + super(message); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/exception/NotFoundException.java b/tinyurl/src/main/java/com/tinyurl/exception/NotFoundException.java new file mode 100644 index 0000000..d837e54 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package com.tinyurl.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/repository/JpaUrlRepository.java b/tinyurl/src/main/java/com/tinyurl/repository/JpaUrlRepository.java new file mode 100644 index 0000000..68f912e --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/repository/JpaUrlRepository.java @@ -0,0 +1,40 @@ +package com.tinyurl.repository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.Optional; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class JpaUrlRepository implements UrlRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public long nextSequenceVal() { + Object value = entityManager.createNativeQuery("SELECT nextval('url_seq')").getSingleResult(); + return ((Number) value).longValue(); + } + + @Override + @Transactional + public UrlMappingEntity save(UrlMappingEntity entity) { + entityManager.persist(entity); + return entity; + } + + @Override + @Transactional(readOnly = true) + public Optional findByShortCode(String shortCode) { + return entityManager.createQuery( + "SELECT u FROM UrlMappingEntity u WHERE u.shortCode = :shortCode", + UrlMappingEntity.class + ) + .setParameter("shortCode", shortCode) + .getResultStream() + .findFirst(); + } +} diff --git a/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java b/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java new file mode 100644 index 0000000..ac8c535 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/repository/UrlMappingEntity.java @@ -0,0 +1,74 @@ +package com.tinyurl.repository; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "url_mappings") +public class UrlMappingEntity { + + @Id + private Long id; + + @Column(name = "short_code", nullable = false, unique = true, length = 32) + private String shortCode; + + @Column(name = "original_url", nullable = false, length = 2048) + private String originalUrl; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + @Column(name = "has_explicit_expiry", nullable = false) + private boolean hasExplicitExpiry; + + protected UrlMappingEntity() { + } + + public UrlMappingEntity( + Long id, + String shortCode, + String originalUrl, + OffsetDateTime createdAt, + OffsetDateTime expiresAt, + boolean hasExplicitExpiry + ) { + this.id = id; + this.shortCode = shortCode; + this.originalUrl = originalUrl; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.hasExplicitExpiry = hasExplicitExpiry; + } + + public Long getId() { + return id; + } + + public String getShortCode() { + return shortCode; + } + + public String getOriginalUrl() { + return originalUrl; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public boolean hasExplicitExpiry() { + return hasExplicitExpiry; + } + +} diff --git a/tinyurl/src/main/java/com/tinyurl/repository/UrlRepository.java b/tinyurl/src/main/java/com/tinyurl/repository/UrlRepository.java new file mode 100644 index 0000000..ff88a71 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/repository/UrlRepository.java @@ -0,0 +1,9 @@ +package com.tinyurl.repository; + +import java.util.Optional; + +public interface UrlRepository { + long nextSequenceVal(); + UrlMappingEntity save(UrlMappingEntity entity); + Optional findByShortCode(String shortCode); +} diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlService.java b/tinyurl/src/main/java/com/tinyurl/service/UrlService.java new file mode 100644 index 0000000..1ab85ac --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlService.java @@ -0,0 +1,10 @@ +package com.tinyurl.service; + +import com.tinyurl.dto.CreateUrlRequest; +import com.tinyurl.dto.UrlMapping; +import java.util.Optional; + +public interface UrlService { + UrlMapping shortenUrl(CreateUrlRequest request); + Optional resolveCode(String code); +} diff --git a/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java b/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java new file mode 100644 index 0000000..4aed844 --- /dev/null +++ b/tinyurl/src/main/java/com/tinyurl/service/UrlServiceImpl.java @@ -0,0 +1,105 @@ +package com.tinyurl.service; + +import com.tinyurl.config.AppProperties; +import com.tinyurl.dto.CreateUrlRequest; +import com.tinyurl.dto.UrlMapping; +import com.tinyurl.encoding.Base62Encoder; +import com.tinyurl.exception.GoneException; +import com.tinyurl.repository.UrlMappingEntity; +import com.tinyurl.repository.UrlRepository; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class UrlServiceImpl implements UrlService { + + private static final int MAX_EXPIRY_DAYS = 3650; + + private final UrlRepository urlRepository; + private final Base62Encoder base62Encoder; + private final AppProperties appProperties; + + public UrlServiceImpl(UrlRepository urlRepository, Base62Encoder base62Encoder, AppProperties appProperties) { + this.urlRepository = urlRepository; + this.base62Encoder = base62Encoder; + this.appProperties = appProperties; + } + + @Override + public UrlMapping shortenUrl(CreateUrlRequest request) { + validateUrl(request.url()); + boolean hasExplicitExpiry = request.expiresInDays() != null; + int expiresInDays = normalizeExpiry(request.expiresInDays()); + + long id = urlRepository.nextSequenceVal(); + String shortCode = base62Encoder.encode(id); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + OffsetDateTime expiresAt = now.plusDays(expiresInDays); + + UrlMappingEntity entity = new UrlMappingEntity( + id, + shortCode, + request.url(), + now, + expiresAt, + hasExplicitExpiry + ); + + UrlMappingEntity persisted = urlRepository.save(entity); + return toDomain(persisted); + } + + @Override + public Optional resolveCode(String code) { + Optional maybeEntity = urlRepository.findByShortCode(code); + if (maybeEntity.isEmpty()) { + return Optional.empty(); + } + + UrlMappingEntity entity = maybeEntity.get(); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + if (entity.getExpiresAt().isBefore(now)) { + throw new GoneException("This short URL has expired or been removed."); + } + + return Optional.of(toDomain(entity)); + } + + private UrlMapping toDomain(UrlMappingEntity entity) { + return new UrlMapping( + entity.getId(), + entity.getShortCode(), + entity.getOriginalUrl(), + entity.getExpiresAt(), + entity.hasExplicitExpiry() + ); + } + + private int normalizeExpiry(Integer expiresInDays) { + int configuredDefault = appProperties.defaultExpiryDays() == null ? 180 : appProperties.defaultExpiryDays(); + int value = expiresInDays == null ? configuredDefault : expiresInDays; + if (value <= 0 || value > MAX_EXPIRY_DAYS) { + throw new IllegalArgumentException("INVALID_EXPIRY"); + } + return value; + } + + private void validateUrl(String rawUrl) { + try { + URI uri = new URI(rawUrl); + String scheme = uri.getScheme(); + if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) { + throw new IllegalArgumentException("INVALID_URL"); + } + if (!uri.isAbsolute() || uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("INVALID_URL"); + } + } catch (URISyntaxException | IllegalArgumentException ex) { + throw new IllegalArgumentException("INVALID_URL"); + } + } +} diff --git a/tinyurl/src/main/resources/db/migration/V1__init_schema.sql b/tinyurl/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..28ca4ef --- /dev/null +++ b/tinyurl/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,12 @@ +CREATE SEQUENCE IF NOT EXISTS url_seq START WITH 1000 INCREMENT BY 1; + +CREATE TABLE IF NOT EXISTS url_mappings ( + id BIGINT PRIMARY KEY, + short_code VARCHAR(32) NOT NULL UNIQUE, + original_url VARCHAR(2048) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + has_explicit_expiry BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_url_mappings_short_code ON url_mappings(short_code); From fb3e592a7e2a2b77d097d849d934df56a00582c6 Mon Sep 17 00:00:00 2001 From: Buffden Date: Tue, 17 Mar 2026 01:43:58 -0500 Subject: [PATCH 4/5] Add focused unit tests for encoder and URL service rules --- .../encoding/Base62EncoderImplTest.java | 28 ++++ .../tinyurl/service/UrlServiceImplTest.java | 141 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java create mode 100644 tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java diff --git a/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java b/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java new file mode 100644 index 0000000..24eb768 --- /dev/null +++ b/tinyurl/src/test/java/com/tinyurl/encoding/Base62EncoderImplTest.java @@ -0,0 +1,28 @@ +package com.tinyurl.encoding; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class Base62EncoderImplTest { + + private final Base62EncoderImpl encoder = new Base62EncoderImpl(); + + @Test + void encodeShouldPadToAtLeastSixCharacters() { + assertEquals(6, encoder.encode(1).length()); + assertEquals("000000", encoder.encode(0)); + } + + @Test + void decodeShouldReturnOriginalValue() { + long value = 123456789L; + assertEquals(value, encoder.decode(encoder.encode(value))); + } + + @Test + void decodeShouldRejectInvalidCode() { + assertThrows(IllegalArgumentException.class, () -> encoder.decode("***")); + } +} diff --git a/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java b/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java new file mode 100644 index 0000000..c562b1d --- /dev/null +++ b/tinyurl/src/test/java/com/tinyurl/service/UrlServiceImplTest.java @@ -0,0 +1,141 @@ +package com.tinyurl.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.tinyurl.config.AppProperties; +import com.tinyurl.dto.CreateUrlRequest; +import com.tinyurl.dto.UrlMapping; +import com.tinyurl.encoding.Base62Encoder; +import com.tinyurl.exception.GoneException; +import com.tinyurl.repository.UrlMappingEntity; +import com.tinyurl.repository.UrlRepository; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UrlServiceImplTest { + + @Mock + private UrlRepository urlRepository; + + @Mock + private Base62Encoder base62Encoder; + + private UrlServiceImpl service; + + @BeforeEach + void setUp() { + service = new UrlServiceImpl(urlRepository, base62Encoder, new AppProperties("http://localhost", 180, 6)); + } + + @Test + void shortenUrlShouldRejectMalformedUrl() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.shortenUrl(new CreateUrlRequest("not-a-url", 30)) + ); + assertEquals("INVALID_URL", ex.getMessage()); + } + + @Test + void shortenUrlShouldRejectNonHttpUrl() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.shortenUrl(new CreateUrlRequest("ftp://example.com", 30)) + ); + assertEquals("INVALID_URL", ex.getMessage()); + } + + @Test + void shortenUrlShouldRejectZeroExpiry() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.shortenUrl(new CreateUrlRequest("https://example.com", 0)) + ); + assertEquals("INVALID_EXPIRY", ex.getMessage()); + } + + @Test + void shortenUrlShouldRejectTooLargeExpiry() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.shortenUrl(new CreateUrlRequest("https://example.com", 3651)) + ); + assertEquals("INVALID_EXPIRY", ex.getMessage()); + } + + @Test + void shortenUrlShouldUseDefaultExpiryAndMarkAsNonExplicit() { + when(urlRepository.nextSequenceVal()).thenReturn(1000L); + when(base62Encoder.encode(1000L)).thenReturn("0000Ab"); + when(urlRepository.save(any(UrlMappingEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + OffsetDateTime before = OffsetDateTime.now(ZoneOffset.UTC); + UrlMapping result = service.shortenUrl(new CreateUrlRequest("https://example.com/default", null)); + OffsetDateTime after = OffsetDateTime.now(ZoneOffset.UTC); + + assertEquals("0000Ab", result.shortCode()); + assertEquals("https://example.com/default", result.originalUrl()); + assertTrue(!result.explicitExpiry()); + assertTrue(!result.expiresAt().isBefore(before.plusDays(180))); + assertTrue(!result.expiresAt().isAfter(after.plusDays(180))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UrlMappingEntity.class); + verify(urlRepository).save(captor.capture()); + assertTrue(!captor.getValue().hasExplicitExpiry()); + } + + @Test + void shortenUrlShouldUseProvidedExpiryAndMarkAsExplicit() { + when(urlRepository.nextSequenceVal()).thenReturn(1001L); + when(base62Encoder.encode(1001L)).thenReturn("0000Ac"); + when(urlRepository.save(any(UrlMappingEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + OffsetDateTime before = OffsetDateTime.now(ZoneOffset.UTC); + UrlMapping result = service.shortenUrl(new CreateUrlRequest("https://example.com/explicit", 30)); + OffsetDateTime after = OffsetDateTime.now(ZoneOffset.UTC); + + assertEquals("0000Ac", result.shortCode()); + assertTrue(result.explicitExpiry()); + assertTrue(!result.expiresAt().isBefore(before.plusDays(30))); + assertTrue(!result.expiresAt().isAfter(after.plusDays(30))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UrlMappingEntity.class); + verify(urlRepository).save(captor.capture()); + assertTrue(captor.getValue().hasExplicitExpiry()); + } + + @Test + void resolveCodeShouldReturnEmptyWhenMissing() { + when(urlRepository.findByShortCode("0000Ab")).thenReturn(Optional.empty()); + assertTrue(service.resolveCode("0000Ab").isEmpty()); + } + + @Test + void resolveCodeShouldThrowGoneWhenExpired() { + UrlMappingEntity expired = new UrlMappingEntity( + 1002L, + "0000Ad", + "https://example.com/expired", + OffsetDateTime.now(ZoneOffset.UTC).minusDays(40), + OffsetDateTime.now(ZoneOffset.UTC).minus(1, ChronoUnit.MINUTES), + true + ); + when(urlRepository.findByShortCode("0000Ad")).thenReturn(Optional.of(expired)); + + assertThrows(GoneException.class, () -> service.resolveCode("0000Ad")); + } +} From deccc4de40474f34294fe6f9d9434a252f10b4fe Mon Sep 17 00:00:00 2001 From: Buffden Date: Tue, 17 Mar 2026 01:45:41 -0500 Subject: [PATCH 5/5] add test files --- .../java/com/tinyurl/TinyurlApplicationTests.java | 14 ++++++++++++++ tinyurl/src/test/resources/application-test.yaml | 11 +++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tinyurl/src/test/java/com/tinyurl/TinyurlApplicationTests.java create mode 100644 tinyurl/src/test/resources/application-test.yaml diff --git a/tinyurl/src/test/java/com/tinyurl/TinyurlApplicationTests.java b/tinyurl/src/test/java/com/tinyurl/TinyurlApplicationTests.java new file mode 100644 index 0000000..55c5e45 --- /dev/null +++ b/tinyurl/src/test/java/com/tinyurl/TinyurlApplicationTests.java @@ -0,0 +1,14 @@ +package com.tinyurl; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class TinyurlApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/tinyurl/src/test/resources/application-test.yaml b/tinyurl/src/test/resources/application-test.yaml new file mode 100644 index 0000000..6869db7 --- /dev/null +++ b/tinyurl/src/test/resources/application-test.yaml @@ -0,0 +1,11 @@ +spring: + datasource: + url: jdbc:h2:mem:tinyurl;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + flyway: + enabled: false