3

As a homework I have to do a simple URL shortener, where I can add full link to list, which is processed by Hashids.net library, and I get short version of an URL.

CLICK

I've got something like this now, but I got stuck on redirecting it back to full link.

I would like to add a new controller, which will take the responsibility of redirecting short URL to full URL. After clicking short URL it should go to localhost:xxxx/ShortenedUrl and then redirect to full link. Any tips how can I create this?

I was trying to do it by @Html.ActionLink(@item.ShortenedLink, "Index", "Redirect") and return Redirect(fullLink) in Redirect controller but it didn't work as I expect.

And one more question about routes, how can I achieve that after clicking short URL it will give me localhost:XXXX/ShortenedURL (i.e. localhost:XXXX/FSIAOFJO2@). Now I've got

<a href="@item.ShortenedLink">@Html.DisplayFor(model => item.ShortenedLink)</a> 

and

app.UseMvc(routes =>
{ 
    routes.MapRoute("default", "{controller=Link}/{action=Index}");
});

but it gives me localhost:XXXX/Link/ShortenedURL, so I would like to omit this Link in URL.

View (part with Short URL):

 <td>@Html.ActionLink(item.ShortenedLink,"GoToFull","Redirect", new { target = "_blank" }))</td>

Link controller:

public class LinkController : Controller
{
    private ILinksRepository _repository;

    public LinkController(ILinksRepository linksRepository)
    {
        _repository = linksRepository;
    }

    [HttpGet]
    public IActionResult Index()
    {
        var links = _repository.GetLinks();
        return View(links);
    }

    [HttpPost]
    public IActionResult Create(Link link)
    {
        _repository.AddLink(link);
        return Redirect("Index");
    }

    [HttpGet]
    public IActionResult Delete(Link link)
    {
        _repository.DeleteLink(link);
        return Redirect("Index");
    }
}

Redirect controller which I am trying to do:

private ILinksRepository _repository;

public RedirectController(ILinksRepository linksRepository)
{
    _repository = linksRepository;
}

public IActionResult GoToFull()
{
    var links = _repository.GetLinks();
    return Redirect(links[0].FullLink);
}

Is there a better way to get access to links list in Redirect Controller?

McGuireV10
  • 9,572
  • 5
  • 48
  • 64
exdrax
  • 41
  • 6

2 Answers2

1

This is my suggestion, trigger the link via AJAX, here is working example:

This is the HTML element binded through model:

@Html.ActionLink(Model.ShortenedLink, "", "", null, 
new { onclick = "fncTrigger('" + "http://www.google.com" + "');" })

This is the javascript ajax code:

function fncTrigger(id) {

            $.ajax({
                url: '@Url.Action("TestDirect", "Home")',
                type: "GET",
                data: { id: id },
                success: function (e) {
                },
                error: function (err) {
                    alert(err);
                },
            });
    }

Then on your controller to receive the ajax click:

 public ActionResult TestDirect(string id)
    {
        return JavaScript("window.location = '" + id + "'");
    }

Basically what I am doing here is that, after I click the link, it will call the TestDirect action, then redirect it to using the passed url parameter. You can do the conversion inside this action.

Willy David Jr
  • 8,604
  • 6
  • 46
  • 57
0

To create dynamic data-driven URLs, you need to create a custom IRouter. Here is how it can be done:

CachedRoute<TPrimaryKey>

This is a reusable generic class that maps a set of dynamically provided URLs to a single action method. You can inject an ICachedRouteDataProvider<TPrimaryKey> to provide the data (a URL to primary key mapping).

The data is cached to prevent multiple simultaneous requests from overloading the database (routes run on every request). The default cache time is for 15 minutes, but you can adjust as necessary for your requirements.

If you want it to act "immediate", you could build a more advanced cache that is updated just after a successful database update of one of the records. That is, the same action method would update both the database and the cache.

using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public class CachedRoute<TPrimaryKey> : IRouter
{
    private readonly string _controller;
    private readonly string _action;
    private readonly ICachedRouteDataProvider<TPrimaryKey> _dataProvider;
    private readonly IMemoryCache _cache;
    private readonly IRouter _target;
    private readonly string _cacheKey;
    private object _lock = new object();

    public CachedRoute(
        string controller,
        string action,
        ICachedRouteDataProvider<TPrimaryKey> dataProvider,
        IMemoryCache cache,
        IRouter target)
    {
        if (string.IsNullOrWhiteSpace(controller))
            throw new ArgumentNullException("controller");
        if (string.IsNullOrWhiteSpace(action))
            throw new ArgumentNullException("action");
        if (dataProvider == null)
            throw new ArgumentNullException("dataProvider");
        if (cache == null)
            throw new ArgumentNullException("cache");
        if (target == null)
            throw new ArgumentNullException("target");

        _controller = controller;
        _action = action;
        _dataProvider = dataProvider;
        _cache = cache;
        _target = target;

        // Set Defaults
        CacheTimeoutInSeconds = 900;
        _cacheKey = "__" + this.GetType().Name + "_GetPageList_" + _controller + "_" + _action;
    }

    public int CacheTimeoutInSeconds { get; set; }

    public async Task RouteAsync(RouteContext context)
    {
        var requestPath = context.HttpContext.Request.Path.Value;

        if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')
        {
            // Trim the leading slash
            requestPath = requestPath.Substring(1);
        }

        // Get the page id that matches.
        TPrimaryKey id;

        //If this returns false, that means the URI did not match
        if (!GetPageList().TryGetValue(requestPath, out id))
        {
            return;
        }

        //Invoke MVC controller/action
        var routeData = context.RouteData;

        // TODO: You might want to use the page object (from the database) to
        // get both the controller and action, and possibly even an area.
        // Alternatively, you could create a route for each table and hard-code
        // this information.
        routeData.Values["controller"] = _controller;
        routeData.Values["action"] = _action;

        // This will be the primary key of the database row.
        // It might be an integer or a GUID.
        routeData.Values["id"] = id;

        await _target.RouteAsync(context);
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        VirtualPathData result = null;
        string virtualPath;

        if (TryFindMatch(GetPageList(), context.Values, out virtualPath))
        {
            result = new VirtualPathData(this, virtualPath);
        }

        return result;
    }

    private bool TryFindMatch(IDictionary<string, TPrimaryKey> pages, IDictionary<string, object> values, out string virtualPath)
    {
        virtualPath = string.Empty;
        TPrimaryKey id;
        object idObj;
        object controller;
        object action;

        if (!values.TryGetValue("id", out idObj))
        {
            return false;
        }

        id = SafeConvert<TPrimaryKey>(idObj);
        values.TryGetValue("controller", out controller);
        values.TryGetValue("action", out action);

        // The logic here should be the inverse of the logic in 
        // RouteAsync(). So, we match the same controller, action, and id.
        // If we had additional route values there, we would take them all 
        // into consideration during this step.
        if (action.Equals(_action) && controller.Equals(_controller))
        {
            // The 'OrDefault' case returns the default value of the type you're 
            // iterating over. For value types, it will be a new instance of that type. 
            // Since KeyValuePair<TKey, TValue> is a value type (i.e. a struct), 
            // the 'OrDefault' case will not result in a null-reference exception. 
            // Since TKey here is string, the .Key of that new instance will be null.
            virtualPath = pages.FirstOrDefault(x => x.Value.Equals(id)).Key;
            if (!string.IsNullOrEmpty(virtualPath))
            {
                return true;
            }
        }
        return false;
    }

    private IDictionary<string, TPrimaryKey> GetPageList()
    {
        IDictionary<string, TPrimaryKey> pages;

        if (!_cache.TryGetValue(_cacheKey, out pages))
        {
            // Only allow one thread to poplate the data
            lock (_lock)
            {
                if (!_cache.TryGetValue(_cacheKey, out pages))
                {
                    pages = _dataProvider.GetPageToIdMap();

                    _cache.Set(_cacheKey, pages,
                        new MemoryCacheEntryOptions()
                        {
                            Priority = CacheItemPriority.NeverRemove,
                            AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(this.CacheTimeoutInSeconds)
                        });
                }
            }
        }

        return pages;
    }

    private static T SafeConvert<T>(object obj)
    {
        if (typeof(T).Equals(typeof(Guid)))
        {
            if (obj.GetType() == typeof(string))
            {
                return (T)(object)new Guid(obj.ToString());
            }
            return (T)(object)Guid.Empty;
        }
        return (T)Convert.ChangeType(obj, typeof(T));
    }
}

LinkCachedRouteDataProvider

Here we have a simple service that retrieves the data from the database and loads it into a Dictionary. The most complicated part is the scope that needs to be setup in order to use DbContext from within the service.

public interface ICachedRouteDataProvider<TPrimaryKey>
{
    IDictionary<string, TPrimaryKey> GetPageToIdMap();
}

public class LinkCachedRouteDataProvider : ICachedRouteDataProvider<int>
{
    private readonly IServiceProvider serviceProvider;

    public LinkCachedRouteDataProvider(IServiceProvider serviceProvider)
    {
        this.serviceProvider = serviceProvider
            ?? throw new ArgumentNullException(nameof(serviceProvider));
    }

    public IDictionary<string, int> GetPageToIdMap()
    {
        using (var scope = serviceProvider.CreateScope())
        {
            var dbContext = scope.ServiceProvider.GetService<ApplicationDbContext>();

            return (from link in dbContext.Links
                    select new KeyValuePair<string, int>(
                        link.ShortenedLink.Trim('/'),
                        link.Id)
                    ).ToDictionary(pair => pair.Key, pair => pair.Value);
        }
    }
}

RedirectController

Our redirect controller accepts the primary key as an id parameter and then looks up the database record to get the URL to redirect to.

public class RedirectController
{
    private readonly ApplicationDbContext dbContext;

    public RedirectController(ApplicationDbContext dbContext)
    {
        this.dbContext = dbContext
            ?? throw new ArgumentNullException(nameof(dbContext));
    }

    public IActionResult GoToFull(int id)
    {
        var link = dbContext.Links.FirstOrDefault(x => x.Id == id);
        return new RedirectResult(link.FullLink);
    }
}

In a production scenario, you would probably want to make this a permanent redirect return new RedirectResult(link.FullLink, true), but those are automatically cached by browsers which makes testing difficult.

Startup.cs

We setup the DbContext, the memory cache, and the LinkCachedRouteDataProvider in our DI container for use later.

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

    services.AddMvc();

    services.AddMemoryCache();
    services.AddSingleton<LinkCachedRouteDataProvider>();
}

And then we setup our routing using the CachedRoute<TPrimaryKey>, providing all dependencies.

app.UseMvc(routes =>
{
    routes.Routes.Add(new CachedRoute<int>(
        controller: "Redirect",
        action: "GoToFull",
        dataProvider: app.ApplicationServices.GetService<LinkCachedRouteDataProvider>(),
        cache: app.ApplicationServices.GetService<IMemoryCache>(),
        target: routes.DefaultHandler)
        // Set to 60 seconds of caching to make DB updates refresh quicker
        { CacheTimeoutInSeconds = 60 });

    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");
});

To build these short URLs on the user interface, you can use tag helpers (or HTML helpers) the same way you would with any other route:

<a asp-area="" asp-controller="Redirect" asp-action="GoToFull" asp-route-id="1">
    @Url.Action("GoToFull", "Redirect", new { id = 1 })
</a>

Which is generated as:

<a href="/M81J1w0A">/M81J1w0A</a>

You can of course use a model to pass the id parameter into your view when it is generated.

<a asp-area="" asp-controller="Redirect" asp-action="GoToFull" asp-route-id="@Model.Id">
    @Url.Action("GoToFull", "Redirect", new { id = Model.Id })
</a>

I have made a Demo on GitHub. If you enter the short URLs into the browser, they will be redirected to the long URLs.

  • M81J1w0A -> https://maps.google.com/
  • r33NW8K -> https://stackoverflow.com/

I didn't create any of the views to update the URLs in the database, but that type of thing is covered in several tutorials such as Get started with ASP.NET Core MVC and Entity Framework Core using Visual Studio, and it doesn't look like you are having issues with that part.

References:

NightOwl888
  • 55,572
  • 24
  • 139
  • 212