How to build a URL Shortener with C# .NET and Redis
Niels Swimberghe - - .NET
Follow me on Twitter, buy me a coffee
This blog post was written for Twilio and originally published at the Twilio blog.
URLs are used to locate resources on the internet, but URLs can be long making them hard to use. Especially when you have to type the URL manually, even more so when having to do so on a mobile phone. Long URLs are also problematic when you have a limited amount of characters such as within an SMS segment or a Tweet.
A solution to this is to use a URL shortener which will create URLs that are short and sweet. When you open the shortened URL, the URL will forward you to the long destination URL. This makes it easier to manually type the URL and also save precious characters.
Shortened URLs also obfuscate the real URL so users don't know where they will land on the internet when they click the URL. This could be abused by malicious actors, so make sure your URL shortener is secure! If you're using a free URL shortener, users may be wary because anyone could've created the shortened URL. Using your own branded domain name will establish more trust in the shortened URLs you send.
In this tutorial, you'll learn how to create your own URL shortener using C#, .NET, and Redis.
Prerequisites #
Here 's what you will need to follow along:
- .NET 6 SDK (later versions will work too)
- A code editor or IDE. I recommend JetBrains Rider, Visual Studio, or VS Code with the C# plugin.
- A Redis database
You can find the source code for this tutorial on GitHub. Use it if you run into any issues, or submit an issue, if you run into problems.
Before creating your application, let's take a look at how URL shorteners work and what you'll be building.
URL Shortener Solution #
There are two parts to URL shorteners: forwarding the shortened URLs to the destination URL and the management of the shortened URLs. While these two parts could live within a single application, you will build separate applications for URL management and for URL forwarding.
The two applications will work together as shown in the diagram above.
An administrator can use the CLI CRUD (Create Read Update Delete) application (1) to manage the shortened URL data in a Redis Database (2).
Let's say a mobile user received an SMS with a shortened URL. When the user clicks on the shortened URL, the web browser sends an HTTP GET request to that URL which is received by the Forwarder App (1). The forwarder app will look at the path of the request and look up the destination URL in the same Redis Database and return it to the Forwarder App (2). The Forwarder App then sends back an HTTP response with status code 307 Temporary Redirect pointing towards the destination URL (3).
You'll build the Forwarder App using an ASP.NET Core Minimal API, and you'll also build a command-line interface (CLI) application to provide the CRUD functionality. The CLI application will take advantage of the open-source System.CommandLine libraries. While these libraries are still in preview, they work well and provide common functionality needed to build CLI applications. While you'll build the CRUD functionality in a CLI app, you could build the same app as a website or API using ASP.NET Core, or client app using WPF, WinForms, Avalonia, MAUI, etc.
The CLI application will store the collection of shortened URLs in a Redis database. The key of each shortened URL will be the unique path that will be used to browse to the URL, and the value will be the destination URL.
I chose Redis for this application because it works well as a key-value database, it is well-supported, and there's a great .NET library to interact with Redis. However, you could use any data store you'd like in your own implementation.
Lastly, since these two applications will interact with the same data store and perform some of the same functionality, you'll create a class library for shared functionality for the CRUD operations and validation.
Create the Data Layer #
First, open a terminal and run the following commands to create a folder, navigate into it, and create a solution file using the .NET CLI:
mkdir UrlShortener cd UrlShortener dotnet new sln
Then, create a new class library project for the data layer, and add the library to the solution:
dotnet new classlib -o UrlShortener.Data dotnet sln add UrlShortener.Data
Now open the solution with your preferred editor.
Rename the default C# file called Class1.cs to ShortUrl.cs, and add the following C# code to ShortUrl.cs:
namespace UrlShortener.Data; public sealed record ShortUrl(string? Destination, string? Path);
This record will hold on to the Destination
and the Path
. Path
is the unique key that will be matched with the path from the incoming HTTP request in the Forwarder App, and Destination
will be the URL the Forwarder App will forward the user to.
Next, create the ShortUrlValidator.cs file which will have code to validate the ShortUrl
and its properties. Then, add the following C# code to the file:
using System.Text.RegularExpressions; namespace UrlShortener.Data; public static class ShortUrlValidator { private static readonly Regex PathRegex = new Regex( "^[a-zA-Z0-9_-]*$", RegexOptions.None, TimeSpan.FromMilliseconds(1) ); public static bool Validate(this ShortUrl shortUrl, out IDictionary<string, string[]> validationResults) { validationResults = new Dictionary<string, string[]>(); var isDestinationValid = ValidateDestination( shortUrl.Destination, out var destinationValidationResults ); var isPathValid = ValidatePath( shortUrl.Path, out var pathValidationResults ); validationResults.Add("destination", destinationValidationResults); validationResults.Add("path", pathValidationResults); return isDestinationValid && isPathValid; } public static bool ValidateDestination(string? destination, out string[] validationResults) { if (destination == null) { validationResults = new[] {"Destination cannot be null."}; return false; } if (destination == "") { validationResults = new[] {"Destination cannot empty."}; return false; } if (!Uri.IsWellFormedUriString(destination, UriKind.Absolute)) { validationResults = new[] {"Destination has to be a valid absolute URL."}; return false; } validationResults = Array.Empty<string>(); return true; } public static bool ValidatePath(string? path, out string[] validationResults) { if (path == null) { validationResults = new[] {"Path cannot be null."}; return false; } if (path == "") { validationResults = new[] {"Path cannot empty."}; return false; } var validationResultsList = new List<string>(); if (path.Length > 10) validationResultsList.Add("Path cannot be longer than 10 characters."); if (!PathRegex.IsMatch(path)) validationResultsList.Add("Path can only contain alphanumeric characters, underscores, and dashes."); validationResults = validationResultsList.ToArray(); return validationResultsList.Count > 0; } }
ShortUrlValidator
has methods for validating the ShortUrl
record and for its individual properties. For each validation rule that is not met, a string is added to the validationResults
list. The validationResults
are then added together into a dictionary. These are:
- The
ShortUrl.Path
property can not be null or empty, not be longer than 10 characters, and has to match thePathRegex
which only allows alphanumeric characters, underscores, and dashes. - The
ShortUrl.Destination
property can not be null or empty, and has to be a well formed absolute URL, meaning a URL of format<scheme>://<hostname>:<port>
and optionally a path, query, and fragment.
These validation rules will be used in both the Forwarder App and the CLI CRUD App.
If you don't want to manually write your validation code, there are some great libraries out there that can help such as FluentValidation for any type of project, and MiniValidation to get the same validation experience as in ASP.NET Core MVC.
Now, the most important responsibility of this project is to manage the data in the Redis database. There's a great library for .NET by StackExchange, the StackOverflow company, for interacting with Redis databases called StackExchange.Redis. Add the StackExchange.Redis NuGet package to the data project using the .NET CLI:
dotnet add UrlShortener.Data package StackExchange.Redis
Now, create a new file ShortUrlRepository.cs and add the following C# code:
namespace UrlShortener.Data; using StackExchange.Redis; public sealed class ShortUrlRepository { private readonly ConnectionMultiplexer redisConnection; private readonly IDatabase redisDatabase; public ShortUrlRepository(ConnectionMultiplexer redisConnection) { this.redisConnection = redisConnection; this.redisDatabase = redisConnection.GetDatabase(); } public async Task Create(ShortUrl shortUrl) { if (await Exists(shortUrl.Path)) throw new Exception($"Shortened URL with path '{shortUrl.Path}' already exists."); var urlWasSet = await redisDatabase.StringSetAsync(shortUrl.Path, shortUrl.Destination); if (!urlWasSet) throw new Exception($"Failed to create shortened URL."); } public async Task Update(ShortUrl shortUrl) { if (await Exists(shortUrl.Path) == false) throw new Exception($"Shortened URL with path '{shortUrl.Path}' does not exist."); var urlWasSet = await redisDatabase.StringSetAsync(shortUrl.Path, shortUrl.Destination); if (!urlWasSet) throw new Exception($"Failed to update shortened URL."); } public async Task Delete(string path) { if (await Exists(path) == false) throw new Exception($"Shortened URL with path '{path}' does not exist."); var urlWasDeleted = await redisDatabase.KeyDeleteAsync(path); if (!urlWasDeleted) throw new Exception("Failed to delete shortened URL."); } public async Task<ShortUrl?> Get(string path) { if (await Exists(path) == false) throw new Exception($"Shortened URL with path '{path}' does not exist."); var redisValue = await redisDatabase.StringGetAsync(path); if (redisValue.IsNullOrEmpty) return null; return new ShortUrl(redisValue.ToString(), path); } public async Task<List<ShortUrl>> GetAll() { var redisServers = redisConnection.GetServers(); var keys = new List<string>(); foreach (var redisServer in redisServers) { await foreach (var redisKey in redisServer.KeysAsync()) { var key = redisKey.ToString(); if (keys.Contains(key)) continue; keys.Add(key); } } var redisDb = redisConnection.GetDatabase(); var shortUrls = new List<ShortUrl>(); foreach (var key in keys) { var redisValue = redisDb.StringGet(key); shortUrls.Add(new ShortUrl(redisValue.ToString(), key)); } return shortUrls; } public async Task<bool> Exists(string? path) => await redisDatabase.KeyExistsAsync(path); }
The ShortUrlRepository
is responsible for interacting with the Redis database to manage the shortened URLs. ShortUrlRepository
accepts an instance of ConnectionMultiplexer
via its constructor which is used to get an IDatabase
instance via redisConnection.GetDatabase()
. ConnectionMultiplexer
takes care of connecting to the Redis database, while the IDatabase
class provides the Redis commands as .NET methods.
The ShortUrlRepository
has the following methods:
Create
to create aShortUrl
in the Redis database with the key being the path of the shortened URL, and the value being the destination URL.Update
to update an existingShortUrl
in the Redis database.Delete
to delete aShortUrl
by deleting the key in the Redis database.Get
to get theShortUrl
via the path.GetAll
to retrieve all the shortened URLs by retrieving all keys from all the connected Redis servers and then retrieve the values from the Redis database.
This provides all the CRUD functionality for shortened URLs.
Now let's use these classes to create the CRUD CLI App.
Build the Command-Line CRUD Application #
Create a new console project in your solution folder, and add the project to the solution:
dotnet new console -o UrlShortener.Cli dotnet sln add UrlShortener.Cli
Then, add a reference from the CLI project to the data project:
dotnet add UrlShortener.Cli reference UrlShortener.Data
Now you can use the public classes from the data project in your CLI project.
Next, add the StackExchange.Redis NuGet package to this project as well:
dotnet add UrlShortener.Cli package StackExchange.Redis
Now, add the System.CommandLine NuGet package:
dotnet add UrlShortener.Cli package System.CommandLine --version 2.0.0-beta4.22272.1
At the time of writing, there are only prerelease versions of this library available. Once this library is fully released, you'll be able to add it without having to explicitly specify the version or the --prerelease
argument.
The System.CommandLine
libraries provide common functionality for building CLI applications. You can define your arguments and commands in C# and the library will execute your commands, and generate help text, command completion, and more.
Open the UrlShortener.Cli/Program.cs file, and replace the code with the following C#:
using System.CommandLine; using StackExchange.Redis; using UrlShortener.Data; var destinationOption = new Option<string>( new[] {"--destination-url", "-d"}, description: "The URL that the shortened URL will forward to." ); destinationOption.IsRequired = true; destinationOption.AddValidator(result => { var destination = result.Tokens[0].Value; if (ShortUrlValidator.ValidateDestination(destination, out var validationResults) == false) { result.ErrorMessage = string.Join(", ", validationResults); } }); var pathOption = new Option<string>( new[] {"--path", "-p"}, description: "The path used for the shortened URL." ); pathOption.IsRequired = true; pathOption.AddValidator(result => { var path = result.Tokens[0].Value; if (ShortUrlValidator.ValidatePath(path, out var validationResults) == false) { result.ErrorMessage = string.Join(", ", validationResults); } }); var connectionStringOption = new Option<string?>( new[] {"--connection-string", "-c"}, description: "Connection string to connect to the Redis Database where URLs are stored. " + "Alternatively, you can set the 'URL_SHORTENER_CONNECTION_STRING'." ); var envConnectionString = Environment.GetEnvironmentVariable("URL_SHORTENER_CONNECTION_STRING"); if (string.IsNullOrEmpty(envConnectionString)) { connectionStringOption.IsRequired = true; }
This code defines the options that you can pass into the CLI application:
destinationOption
can be passed in using-d
or--destination-url
and is required.pathOption
can be passed in using-p
or--path
and is also required.connectionStringOption
can be passed in using-c
or--connection-string
. Alternatively, you can set theURL_SHORTENER_CONNECTION_STRING
environment variable which is preferred over passing sensitive strings as an argument. If theURL_SHORTENER_CONNECTION_STRING
environment is not present, then theconnectionStringOption
is required.
The destinationOption
and pathOption
validate the argument by passing in the argument value to their respective ShortUrlValidator
methods. The validation results are then concatenated and set to result.ErrorMessage
which will be displayed as errors to the user.
Now that the options are defined, you can create commands that receive the options.
Add the following code after your existing code:
var rootCommand = new RootCommand("Manage the shortened URLs."); async Task<ConnectionMultiplexer> GetRedisConnection(string? connectionString) { var redisConnection = await ConnectionMultiplexer.ConnectAsync( connectionString ?? envConnectionString ?? throw new Exception("Missing connection string.") ); return redisConnection; } var createCommand = new Command("create", "Create a shortened URL") { destinationOption, pathOption, connectionStringOption }; createCommand.SetHandler(async (destination, path, connectionString) => { var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString)); try { await shortUrlRepository.Create(new ShortUrl(destination, path)); Console.WriteLine("Shortened URL created."); } catch (Exception e) { Console.Error.WriteLine(e.Message); } }, destinationOption, pathOption, connectionStringOption); rootCommand.AddCommand(createCommand);
The rootCommand
is the command that is invoked when you invoke the CLI application without passing any subcommands. The create
command takes in the destinationOption
, pathOption
, and connectionStringOption
. The lambda passed into the SetHandler
method receives the values for the options and will be executed when the command is run from the CLI.
Since the connectionStringOption
may not be required, the connectionString
parameter may be null. The GetRedisConnection
method checks if it is null, and if so, returns the value from the environment variables. If that is also null, it'll throw an exception.
The command handler will create a new ShortUrlRepository
passing in the connection string, and use the Create
method to create a new shortened URL.
Now, add the following code which will add the update
, delete
, get
, and list
commands:
var updateCommand = new Command("update", "Update a shortened URL") { destinationOption, pathOption, connectionStringOption }; updateCommand.SetHandler(async (destination, path, connectionString) => { var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString)); try { await shortUrlRepository.Update(new ShortUrl(destination, path)); Console.WriteLine("Shortened URL updated."); } catch (Exception e) { Console.Error.WriteLine(e.Message); } }, destinationOption, pathOption, connectionStringOption); rootCommand.AddCommand(updateCommand); var deleteCommand = new Command("delete", "Delete a shortened URL") { pathOption, connectionStringOption }; deleteCommand.SetHandler(async (path, connectionString) => { var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString)); try { await shortUrlRepository.Delete(path); Console.WriteLine("Shortened URL deleted."); } catch (Exception e) { Console.Error.WriteLine(e.Message); } }, pathOption, connectionStringOption); rootCommand.AddCommand(deleteCommand); var getCommand = new Command("get", "Get a shortened URL") { pathOption, connectionStringOption }; getCommand.SetHandler(async (path, connectionString) => { var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString)); try { var shortUrl = await shortUrlRepository.Get(path); if (shortUrl == null) Console.Error.WriteLine($"Shortened URL for path '{path}' not found."); else Console.WriteLine($"Destination URL: {shortUrl.Destination}, Path: {path}"); } catch (Exception e) { Console.Error.WriteLine(e.Message); } }, pathOption, connectionStringOption); rootCommand.AddCommand(getCommand); var listCommand = new Command("list", "List shortened URLs") { connectionStringOption }; listCommand.SetHandler(async (connectionString) => { var shortUrlRepository = new ShortUrlRepository(await GetRedisConnection(connectionString)); try { var shortUrls = await shortUrlRepository.GetAll(); foreach (var shortUrl in shortUrls) { Console.WriteLine($"Destination URL: {shortUrl.Destination}, Path: {shortUrl.Path}"); } } catch (Exception e) { Console.Error.WriteLine(e.Message); } }, connectionStringOption); rootCommand.AddCommand(listCommand);
Note how some commands take less options than others.
Lastly, add the following line of code:
return rootCommand.InvokeAsync(args).Result;
This line is responsible for running the commands and passing in the args
string array.
Go back to your terminal and configure the URL_SHORTENER_CONNECTION_STRING
environment variable:
If you use PowerShell:
$Env:URL_SHORTENER_CONNECTION_STRING = '[YOUR_CONNECTION_STRING]'
If you use CMD:
set URL_SHORTENER_CONNECTION_STRING=[YOUR_CONNECTION_STRING]
If you use Unix based shells such as Bash or Zsh:
export URL_SHORTENER_CONNECTION_STRING=[YOUR_CONNECTION_STRING]
Replace [YOUR_CONNECTION_STRING]
with the connection string pointing to your Redis server.
Now you can run the project and will see helpful information about the available commands:
dotnet run --project UrlShortener.Cli
The output looks like this:
Required command was not provided. Description: Manage the shortened URLs. Usage: UrlShortener.Cli [command] [options] Options: --version Show version information -?, -h, --help Show help and usage information Commands: create Create a shortened URL update Update a shortened URL delete Delete a shortened URL get Get a shortened URL list List shortened URLs
To get help information about specific commands, specify the command and add the --help
argument:
dotnet run --project UrlShortener.Cli -- create --help
The output looks like this:
Description: Create a shortened URL Usage: UrlShortener.Cli create [options] Options: -d, --destination-url <destination-url> The URL that the shortened URL will forward to. (REQUIRED) -p, --path <path> (REQUIRED) The path used for the shortened URL. -c, --connection-string <connection-string> Connection string to connect to the Redis Database where URLs are stored. Alternatively, you can set the 'URL_SHORTENER_CONNECTION_STRING'. -?, -h, --help Show help and usage information
Next, try the following commands:
dotnet run --project UrlShortener.Cli -- create -d https://www.youtube.com/watch?v=dQw4w9WgXcQ -p rr dotnet run --project UrlShortener.Cli -- create -d https://www.twilio.com -p tw dotnet run --project UrlShortener.Cli -- create -d https://sendgrid.com -p sg dotnet run --project UrlShortener.Cli -- create -d https://swimburger.net -p sb dotnet run --project UrlShortener.Cli -- get -p sb dotnet run --project UrlShortener.Cli -- update -d https://swimburger.net/blog -p sb dotnet run --project UrlShortener.Cli -- list
Alternatively, you could publish the project and interact directly with the executable:
dotnet publish UrlShortener.Cli -o publish cd publish ./UrlShortener.Cli list
Great job! You built the CRUD as a CLI application, now let's build the Forwarder Application.
Build the ASP.NET Core Forwarder Application #
In your terminal, head back to the solution folder and then create a new ASP.NET Core Minimal API project:
cd .. dotnet new web -o UrlShortener.Forwarder
Just like with the CRUD CLI project, add a reference in the Forwarder project to the data project, and add the StackExchange.Redis NuGet package:
dotnet add UrlShortener.Forwarder reference UrlShortener.Data dotnet add UrlShortener.Forwarder package StackExchange.Redis
Now, open UrlShortener.Forwarder/Program.cs and replace the contents with the following code:
using StackExchange.Redis; using UrlShortener.Data; var builder = WebApplication.CreateBuilder(args); var connectionString = builder.Configuration.GetConnectionString("ShortenedUrlsDb") ?? throw new Exception("Missing 'ShortenedUrlsDb' connection string"); var redisConnection = await ConnectionMultiplexer.ConnectAsync(connectionString); builder.Services.AddSingleton(redisConnection); builder.Services.AddTransient<ShortUrlRepository>(); var app = builder.Build(); app.MapGet("/{path}", async ( string path, ShortUrlRepository shortUrlRepository ) => { if(ShortUrlValidator.ValidatePath(path, out _)) return Results.BadRequest(); var shortUrl = await shortUrlRepository.Get(path); if (shortUrl == null || string.IsNullOrEmpty(shortUrl.Destination)) return Results.NotFound(); return Results.Redirect(shortUrl.Destination); }); app.Run();
Let's look at lines 6 to 10 first. The program will retrieve the UrlsDb
Redis connection string from the configuration, and if null, throw an exception. Then, a connection to the Redis server is made which is added to the Dependency Injection (DI) container as a singleton.
Next, the ShortUrlRepository
is added as a transient service to the DI container. Since ShortUrlRepository
accepts a ConnectionMultiplexer
object via its constructor, the DI container is able to construct the ShortUrlRepository
for you passing in the singleton ConnectionMultiplexer
.
Next, let's look at lines 14 to 27. A new HTTP GET endpoint is added with the route /{path}
. This endpoint will be invoked by any HTTP request with a single level path. Without a path, ASP.NET Core will return an HTTP status code of 404 Not Found, and so will requests with subdirectory paths.
When the endpoint is invoked, the path of the URL is passed into the path
parameter of the lambda. The second lambda parameter, an instance of ShortUrlRepository
, is injected by the DI container.
The path is then validated using ShortUrlValidator.ValidatePath
. If the path is not valid, an HTTP status code of 400 Bad Request is returned.
If it is valid, the shortened URL is retrieved using the ShortUrlRepository.Get
method. If no shortened URL is found, shortUrl
will be null. If the shortUrl
is null, or when it is not null but the ShortUrl.Destination
is null or empty, then an HTTP status code 404 Not Found is returned. Otherwise, the endpoint responds with HTTP status code 307 Temporary Redirect
, redirecting to the ShortUrl.Destination
.
Now that your code is ready, you'll need to configure the ShortenedUrlsDb
connection string. Run the following commands to enable user-secrets, and then configure your connection string as a user-secret:
dotnet user-secrets --project UrlShortener.Forwarder init dotnet user-secrets --project UrlShortener.Forwarder set ConnectionStrings:ShortenedUrlsDb [YOUR_CONNECTION_STRING]
Replace [YOUR_CONNECTION_STRING]
with the connection string pointing to your Redis server.
Finally, run the Forwarder application:
dotnet run --project UrlShortener.Forwarder
The output of this command will show you the URLs of your application. Open one of the URLs and try some of the shortened URLs you created earlier, like /rr, /sb, /tw, and /sg.
Next steps #
You've developed a CLI application to manage shortened URLs and a web application that forwards the shortened URLs to the destination URLs. However, to make the URL as short as possible, you need to also buy a domain that is short and host your URL shortener at that domain. For example, Twilio uses twil.io as a short domain, which is much shorter than www.twilio.com. This is also a good example of how the name of your brand can still be represented in your short domain name.
There are some other ways you could improve the solution:
- You could make the
path
optional when creating and updating a shortened URL, and instead randomly generate the path. - You could store a time to live (TTL) for every shortened URL and remove the shortened URL when the TTL expires.
- You could track how many times a shortened URL is used for analytics purposes. You could store this data in the Redis database, or track it as an event in Segment.
- You could add an API that is consumable from other applications. For example, an SMS application could dynamically generate shortened URLs via the API for the long URLs they are trying to send to users.
- You could create a Graphical User Interface (GUI) to manage the shortened URL data instead of a CLI application.
If you want to use shortened URLs with Twilio SMS, Twilio Messaging has a built-in feature called Link Shortening & Click Tracking.
Want to keep learning? Learn how to respond to SMS and Voice calls using ASP.NET Core Minimal APIs.