SpringBoot create a Shortener service

SpringBoot create a Shortener service

Hello friends, I believe that you encountered links like those:
goo.gl,
bit.ly,
vc.com/cc,
youtu.be
and many more, but you can noted, that first service which presented by Google, company closed and transfer all to use their API, which is based on Firebase API.

There are a lot of shorteners and models of monetization that can b applied to your own service as an example you can check following links:
https://bitly.com/
https://cutt.ly/

Let's talk about why could someone wanting to use service like this?  
First of all when you needed to have a short URL, where to use it, for instance if there are limitations on a length of text,

  • if you need to send an SMS text with a link
  • possible that you wanna put a link in a twitter
  • or may be your link should me short and memorable and also represent your company's brand youdomain.co/ideas

Let's see the case with SMS text, if you have a long link, you will need to pay for amount of SMS messages.
That's why this case is interested and can be applied at your work. At my work this has been adopted and customized especially for security reasons.
So let's begin!

Create a shortener service that:

  • а) accept input as a link and response with a generic shorten link
  • b) link can be typed manually, if it's a unique link
  • c) set up a lifetime for link
  • d) generate an QR code
  • e) add functionality to lock it
  • f) shows statistic - language.Ip.address.referer.utm-tags.facebook-pixel, etc
  • g) have an AI-generation shorten name for link - like a slug

In this post we will consider usage of case a), if you'd like to see others variants, type me in comments about it.


We are going to use following technical stack of  Java:
Spring Web, Spring Data, Spring Boot, Java 8+, H2

Go to site start.spring.io and choose necessary dependencies:

do not forget add dependency h2database(H2)!

The task is create shortener as quick as possible, so in a main class we are see following:

@SpringBootApplication
public class ShorterUrlApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShorterUrlApplication.class, args);
    }

}

Create controller, inside we must have 2 main methods for making our service workable,
first - saving original link in DB and generate shorten hash code
second - by requesting a shorten link we need to move client request to original link which is in DB

@RestController
@RequestMapping
class ShorterController {
    Logger logger = LoggerFactory.getLogger(ShorterController.class.getSimpleName());

    @PostMapping(path = "/")
    public String createShortUrl(String original) {
        //TODO generate hash of original URL
        // and return it
        return null;
    }

    @GetMapping(path = "/{hash}")
    public ResponseEntity redirectShorter(@PathVariable("hash") String hash) {
        //TODO find hash in DB and redirect to original URL
		return null;
    }

}

Create ShorterRepository interface for usage Spring Data via CrudRepository<ID, Model> and add a new method for finding by hash-code(findByHash)

interface ShorterRepository extends CrudRepository<Shorter, Long> {
    Shorter findByHash(String hash);
}

What is Shorter(Shortener) - this is our model, in which we keep:
id -  identifier of record
hash - shorten code which matches unique link                                               originalUrl - original link to which we are gonna send
createdAt -time of creation
model can be adopted, it's all depends on complexity of your service, for instance you can sae amount of unique clicks on a shorten link, or it can be information like from which referrer client clicked, or can provide utm tags, etc. But let's keep coding lightly. So our model looking like this:

@Entity
@AllArgsConstructor
@RequiredArgsConstructor
@Getter
@Setter
class Shorter {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    @Column
    private String hash;
    @Column(name = "original_url")
    private String originalUrl;
    @Column(name = "created_at", columnDefinition = "TIMESTAMP")
    private ZonedDateTime createdAt;
}

Here no much details about model, but if there are some questions, please type it in a comments👇
Continue, to test that records exists in DB we need to pre-init structure and import sample data:
- exist a series of opportunities how to init DB and apply migration scripts, which could be a separated post - if you like the idea please comment out about it!
In example schema.sql and put it in the folder src/main/resources

DROP TABLE IF EXISTS shorter;

CREATE TABLE IF NOT EXISTS shorter
(
    id           SERIAL PRIMARY KEY,
    hash         varchar(20) not null unique,
    original_url varchar,
    created_at   timestamp
);

fill with data, create a file data.sql in same directory

INSERT INTO shorter (id, hash, original_url, created_at)
VALUES (NULL, '1', 'https://facebook.com', current_timestamp);

INSERT INTO shorter (id, hash, original_url, created_at)
VALUES (NULL, '2', 'https://mail.google.com/mail/u/0/', current_timestamp);

INSERT INTO shorter (id, hash, original_url, created_at)
VALUES (NULL, '3', 'https://instagram.com', current_timestamp);

There are pitfalls. First of all, if you using JPA and embedded DB your schema.sql will not be started, because the first in priority follows generation of Hibernate DDL.
To solve this problem type in application.properties:

spring.jpa.hibernate.ddl-auto=none

In addition to this starting from version Spring boot 2, scheme init by default only for internal DB. To solve loading of all types of DB, you need to set up following properties:

spring.datasource.initialization-mode=always

Ok we have configured DB, so we finished with infrastructure layer of persistence data  - Repository also called as DAO - data access objects  
Let's inject it in controller:

@RestController
@RequestMapping
class ShorterController {
    Logger logger = LoggerFactory.getLogger(ShorterController.class.getSimpleName());
    
    private final ShorterRepository repository;

	//инжектим репозиторий, в более крупных сервисах и где есть бизнес-логика
    //добавляем слой service-сервис, а уже он взаимодействует с repository
    @Autowired
    public ShorterController(final ShorterRepository repository) {
        this.repository = repository;
    }

    @PostMapping(path = "/")
    public String createShortUrl(String original) {
        //TODO generate hash of original URL
        // and return it
        return null;
    }

    @GetMapping(path = "/{hash}")
    public ResponseEntity redirectShorter(@PathVariable("hash") String hash) {
        //TODO find hash in DB and redirect to original URL
    	return null;
	}
}

Implement method 'redirectShorter':

    @GetMapping(path = "/{hash}")
    public ResponseEntity redirectShorter(@PathVariable("hash") String hash) {
        Shorter shorter = repository.findByHash(hash);
        if (shorter != null) {
        	//если мы нашли код, то делаем редирект на ссылку 
            HttpHeaders headers = new HttpHeaders();
            headers.add("Location", shorter.getOriginalUrl());
            return new ResponseEntity<String>(headers, HttpStatus.FOUND);
        } else {
			//не нашли, выкидываем ошибку не найден 404
            return ResponseEntity.notFound().build();
        }
	}

Hura! Now we have an implementation of redirect and if you enter on links:

localhost:8080/1 - will be redirected to https://facebook.com
localhost:8080/2 - will be redirected to https://mail.google.com/mail/u/0/
localhost:8080/3 - will be redirected to https://instagram.com

And now it's time for most interesting part, how to generate our hash code, to get maximum amount of unique variants in latin, numbers and symbols

[a-zA-Z0-9_-+]

- "a-z"  -  26 
- "A-Z"  -  26
- "0-9"  -  10
- "_-+"  -  3 
= 65 to 6th(the length of hashcode) power 

For instance: fh_ds+-Q - this hash code can be to eighth power
To sum up amount of combinations: 65 to 6th power = 75 million variants

Add utilities class CodeGenerator, according to rules above, add into pom.xml utility library for generation random unique string

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.9</version>
</dependency>

and class:

class CodeGenerator {
    private RandomStringGenerator randomStringGenerator;

    public CodeGenerator() {
        this.randomStringGenerator = new RandomStringGenerator
                .Builder().filteredBy(c -> isLatinLetterOrDigit(c))
                .build();
    }

    public String generate(int length) {
        return randomStringGenerator.generate(length);
    }

    //проверяем
    private static boolean isLatinLetterOrDigit(int codePoint) {
        return ('a' <= codePoint && codePoint <= 'z')
                || ('A' <= codePoint && codePoint <= 'Z')
                || ('0' <= codePoint && codePoint <= '9')
                || ('+' == codePoint)
                || ('_' == codePoint)
                || ('-' == codePoint);

    }

}

inject private final CodeGenerator codeGenerator; in a controller and call a generation, save our model:

@PostMapping(path = "/", consumes = APPLICATION_JSON_VALUE)
    public Shorter createShortUrl(@RequestBody Shorter shorter) {
        String hash = codeGenerator.generate(shorterLength);
        logger.info(hash);
        if(shorter != null) {
            String shorterString = URLDecoder.decode(shorter.getOriginalUrl());
            logger.info(shorterString);
            shorter = new Shorter(null, hash, shorterString, ZonedDateTime.now());
            return repository.save(shorter);
        } else {
            return null;
        }
    }

Ta-da-dam! Success - let's check via cURL creation and receiving a link

Create shorten link via POST request

curl -X POST -d '{"originalUrl": "https://mail.google.com/mail/u/0/#spam"}' "http://localhost:8080/" -H "Content-Type:application/json"

And check usage of shorten link, notes, that you need to add -v operator, otherwise we would not understand does the movement happened or not:

curl -X GET localhost:8080/5YuuXT -v
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /5YuuXT HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 302 
< Location: https://mail.google.com/mail/u/0/#spam
< Content-Length: 0
< Date: Tue, 08 Dec 2020 20:57:22 GMT
< 
* Connection #0 to host localhost left intact
* Closing connection 0

Sources can be found at GitHub-e:
https://github.com/isatimur/shorter-url