Crear un acortador de URLs en Java con Spring Boot (H2) — paso a paso

java Crear un acortador de URLs en Java con Spring Boot (H2) — paso a paso

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

  1. Clona el repositorio o crea el proyecto con la estructura indicada.
  2. Ejecuta: mvn spring-boot:run
  3. POST /api/shorten con JSON: { "url": "https://example.com/very/long" }
  4. Visita el shortUrl devuelto o GET /{code} para redireccionar.
  5. 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión