Crear un acortador de URLs en Java con Spring Boot (H2)
Proyecto práctico: API REST que acorta URLs, redirige, y muestra estadísticas simples. Enfocado a entender la arquitectura, la generación de códigos cortos y cómo evitar colisiones básicas.
Requisitos previos
- JDK 17+
- Maven 3.6+
- IDE (IntelliJ, VS Code, etc.)
- Conceptos básicos de Spring Boot y JPA
Estructura de carpetas
url-shortener/
├─ src/main/java/com/example/urlshortener/
│ ├─ UrlShortenerApplication.java
│ ├─ controller/
│ │ └─ UrlController.java
│ ├─ dto/
│ │ ├─ ShortenRequest.java
│ │ ├─ ShortenResponse.java
│ │ └─ StatsResponse.java
│ ├─ model/
│ │ └─ UrlMapping.java
│ ├─ repository/
│ │ └─ UrlMappingRepository.java
│ └─ service/
│ └─ UrlService.java
├─ src/main/resources/application.properties
└─ pom.xml
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>url-shortener</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.1.0</spring.boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties
# puerto y H2 console
server.port=8080
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:urlshortener
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
# Base URL usado en respuestas (ajusta si despliegas)
app.base-url=http://localhost:8080
Clase principal
package com.example.urlshortener;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UrlShortenerApplication {
public static void main(String[] args) {
SpringApplication.run(UrlShortenerApplication.class, args);
}
}
Entidad: UrlMapping
package com.example.urlshortener.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "url_mapping", indexes = {
@Index(columnList = "code", name = "idx_code", unique = true)
})
public class UrlMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 2048)
private String originalUrl;
@Column(nullable = false, unique = true)
private String code;
@Column(nullable = false)
private Instant createdAt = Instant.now();
@Column(nullable = false)
private long visits = 0L;
// getters & setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getOriginalUrl() { return originalUrl; }
public void setOriginalUrl(String originalUrl) { this.originalUrl = originalUrl; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public long getVisits() { return visits; }
public void setVisits(long visits) { this.visits = visits; }
}
Repositorio
package com.example.urlshortener.repository;
import com.example.urlshortener.model.UrlMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UrlMappingRepository extends JpaRepository<UrlMapping, Long> {
Optional<UrlMapping> findByCode(String code);
boolean existsByCode(String code);
}
Servicio: generación y resolución
package com.example.urlshortener.service;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.repository.UrlMappingRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Optional;
@Service
public class UrlService {
private final UrlMappingRepository repository;
private final SecureRandom random = new SecureRandom();
private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; // base62
private static final int CODE_LENGTH = 7;
public UrlService(UrlMappingRepository repository) {
this.repository = repository;
}
@Transactional
public UrlMapping shorten(String originalUrl) {
// Puedes mejorar validación/normalización aquí
String code = generateUniqueCode();
UrlMapping m = new UrlMapping();
m.setOriginalUrl(originalUrl);
m.setCode(code);
return repository.save(m);
}
public Optional<UrlMapping> findByCode(String code) {
return repository.findByCode(code);
}
@Transactional
public void incrementVisits(UrlMapping m) {
m.setVisits(m.getVisits() + 1);
repository.save(m);
}
private String generateUniqueCode() {
// Trivial loop: genera y verifica unicidad. Para grandes volúmenes optimizar.
String code;
do {
code = randomBase62(CODE_LENGTH);
} while (repository.existsByCode(code));
return code;
}
private String randomBase62(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
int idx = random.nextInt(ALPHABET.length());
sb.append(ALPHABET.charAt(idx));
}
return sb.toString();
}
}
Controlador REST
package com.example.urlshortener.controller;
import com.example.urlshortener.dto.ShortenRequest;
import com.example.urlshortener.dto.ShortenResponse;
import com.example.urlshortener.dto.StatsResponse;
import com.example.urlshortener.model.UrlMapping;
import com.example.urlshortener.service.UrlService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Optional;
@RestController
public class UrlController {
private final UrlService urlService;
@Value("${app.base-url}")
private String baseUrl;
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
@PostMapping("/api/shorten")
public ResponseEntity<ShortenResponse> shorten(@RequestBody ShortenRequest req) {
if (req.getUrl() == null || req.getUrl().isBlank()) {
return ResponseEntity.badRequest().build();
}
UrlMapping saved = urlService.shorten(req.getUrl());
String shortUrl = baseUrl + "/" + saved.getCode();
return ResponseEntity.status(HttpStatus.CREATED).body(new ShortenResponse(saved.getCode(), shortUrl));
}
@GetMapping("/{code}")
public ResponseEntity<Void> redirect(@PathVariable String code) {
Optional<UrlMapping> mapping = urlService.findByCode(code);
if (mapping.isEmpty()) {
return ResponseEntity.notFound().build();
}
UrlMapping m = mapping.get();
urlService.incrementVisits(m);
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(m.getOriginalUrl()));
return new ResponseEntity<>(headers, HttpStatus.FOUND); // 302
}
@GetMapping("/api/stats/{code}")
public ResponseEntity<StatsResponse> stats(@PathVariable String code) {
Optional<UrlMapping> mapping = urlService.findByCode(code);
return mapping.map(m -> ResponseEntity.ok(new StatsResponse(m.getCode(), m.getOriginalUrl(), m.getCreatedAt().toString(), m.getVisits())))
.orElseGet(() -> ResponseEntity.notFound().build());
}
}
DTOs
package com.example.urlshortener.dto;
public record ShortenRequest(String url) { }
package com.example.urlshortener.dto;
public record ShortenResponse(String code, String shortUrl) { }
package com.example.urlshortener.dto;
public record StatsResponse(String code, String originalUrl, String createdAt, long visits) { }
Por qué se diseña así
- Separación de capas (controller, service, repository) para mantener responsabilidades claras y facilitar pruebas.
- Uso de base62 para códigos: compacto y legible. Elegimos longitud 7 para un espacio grande (62^7).
- Verificación de unicidad en la generación: simple y suficiente para cargas pequeñas. En producción usaríamos estrategias mejores (ID encode, hash con retry, o reservados centralizados).
- H2 en memoria para desarrollo; cambiar a PostgreSQL/Redis para producción y persistencia duradera.
- Incremento de visitas transaccional para evitar pérdidas. Para alto tráfico, usar un contador en memoria o Redis y flush periódico.
Cómo ejecutar
- Clona el repositorio o crea el proyecto con la estructura indicada.
- Ejecuta: mvn spring-boot:run
- POST /api/shorten con JSON: { "url": "https://example.com/very/long" }
- Visita el shortUrl devuelto o GET /{code} para redireccionar.
- Consulta estadísticas: GET /api/stats/{code}
Mejoras y consideraciones
- Validar y normalizar URLs (añadir esquema si falta, bloquear URL locales).
- Mecanismo anti-abuso: rate limiting, captchas o verificación para evitar que se creen millones de entradas.
- Para alta disponibilidad: mover generación/validación de código a un servicio con almacenamiento rápido (Redis) y usar sharding o prefix-based codes.
- Analytics avanzadas: registrar timestamp de cada visita, IP anonimizada y user-agent; respetar GDPR y políticas de privacidad.
Para un siguiente paso técnico: reemplaza la estrategia de generación por una que derive el código a partir de un ID secuencial codificado en base62. Esto evita bucles de comprobación de colisiones y escala mejor. También considera proteger los endpoints de creación con autenticación y añadir límites por usuario.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación