Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ target/
build/
dist/
out/
.gradle/

# Spring scaffold helper docs
tinyurl/HELP.md

# Dependency directories
node_modules/
Expand Down
2 changes: 2 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/TinyurlApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

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) {
Expand Down
11 changes: 11 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/config/AppProperties.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse> 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<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("INVALID_URL", messageForCode("INVALID_URL")));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}

@ExceptionHandler(GoneException.class)
public ResponseEntity<ErrorResponse> handleGone(GoneException ex) {
return ResponseEntity.status(HttpStatus.GONE)
.body(new ErrorResponse("GONE", ex.getMessage()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> 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.";
};
}
}
14 changes: 14 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/controller/HealthController.java
Original file line number Diff line number Diff line change
@@ -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<String, String> health() {
return Map.of("status", "UP");
}
}
Original file line number Diff line number Diff line change
@@ -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<Void> 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);
}
}
42 changes: 42 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/controller/UrlController.java
Original file line number Diff line number Diff line change
@@ -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<CreateUrlResponse> 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);
}
}
17 changes: 17 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/dto/CreateUrlRequest.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
11 changes: 11 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/dto/CreateUrlResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.tinyurl.dto;

import java.time.OffsetDateTime;

public record CreateUrlResponse(
String shortUrl,
String shortCode,
String originalUrl,
OffsetDateTime expiresAt
) {
}
7 changes: 7 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/dto/ErrorResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tinyurl.dto;

public record ErrorResponse(
String code,
String message
) {
}
12 changes: 12 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/dto/UrlMapping.java
Original file line number Diff line number Diff line change
@@ -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
) {
}
6 changes: 6 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/encoding/Base62Encoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.tinyurl.encoding;

public interface Base62Encoder {
String encode(long id);
long decode(String code);
}
54 changes: 54 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/encoding/Base62EncoderImpl.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tinyurl.exception;

public class GoneException extends RuntimeException {
public GoneException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tinyurl.exception;

public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
40 changes: 40 additions & 0 deletions tinyurl/src/main/java/com/tinyurl/repository/JpaUrlRepository.java
Original file line number Diff line number Diff line change
@@ -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<UrlMappingEntity> findByShortCode(String shortCode) {
return entityManager.createQuery(
"SELECT u FROM UrlMappingEntity u WHERE u.shortCode = :shortCode",
UrlMappingEntity.class
)
.setParameter("shortCode", shortCode)
.getResultStream()
.findFirst();
}
}
Loading
Loading