diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..059b3d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +################################################################################ +# This .gitignore file was automatically created by Microsoft(R) Visual Studio. +################################################################################ + +/src/shortenurl.unit.test/bin/Debug/netcoreapp2.1 +/src/shortenurl.unit.test/obj +/src/Shortenurl.Model/bin/Debug/netstandard2.0 +/src/Shortenurl.Model/obj +/src/ShortenUrl.API/obj +/src/ShortenUrl.API/bin/Debug/netcoreapp2.1 +/src/.vs diff --git a/Install.md b/Install.md new file mode 100644 index 0000000..4be7659 --- /dev/null +++ b/Install.md @@ -0,0 +1,20 @@ +# Installation Guide for Shorten Urls Service + +### Required Software + + - MS SQL 2012+ + - Visual Studio 2017 , you can download it from [Here](https://pages.github.com/). + +### Install Steps + + - Download Code from GitHub + - Create a DB on SQL and run the script src/Shorten.DB/DDL/CREATE SCHEMA AND SPs.sql on it + - Open the solution src/ShortenUrl.sln + - Change the "DefaultConnection" value with your DB details on shortenurl.api/appsettings.json file +- Run shortenurl.api project + + +### Endpoints + Base Url: https://localhost:[Port]/api/ShortenUrl + + \ No newline at end of file diff --git a/src/Shorten.DB/DDL/CREATE SCHEMA AND SPs.sql b/src/Shorten.DB/DDL/CREATE SCHEMA AND SPs.sql new file mode 100644 index 0000000..6c794cc --- /dev/null +++ b/src/Shorten.DB/DDL/CREATE SCHEMA AND SPs.sql @@ -0,0 +1,64 @@ + +CREATE TABLE ShortUrl +( +ShortUrlId int primary key identity (1,1) not null, +Code varchar(6) COLLATE Latin1_General_CS_AS unique not null, +OriginalUrl nvarchar(2083) not null, +CreatedAt Datetime not null, +LastUsage Datetime, +UsageCount int +) + +GO + +CREATE PROCEDURE uspGetShortUrl +( +@Code varchar(7) +) +AS + +BEGIN + +Select ShortUrlId, Code, OriginalUrl, CreatedAt, LastUsage, UsageCount +FROM ShortUrl +where Code = @Code + +END + +GO + + +CREATE PROCEDURE uspInsertShortUrl +( +@Code varchar(6), +@OriginalUrl nvarchar(2083) +) +AS +BEGIN + +Insert into ShortUrl (Code, OriginalUrl, CreatedAt, UsageCount) values (@Code, @OriginalUrl, GETUTCDATE(), 0) + +END + +GO + + +CREATE PROCEDURE uspUpdateUsage +( +@ShortUrlId int +) +AS + +BEGIN + +Update ShortUrl set LastUsage = GETUTCDATE(), UsageCount = UsageCount + 1 +where ShortUrlId = @ShortUrlId + +END + + + + + + + diff --git a/src/ShortenUrl.API/Controllers/ShortenUrlController.cs b/src/ShortenUrl.API/Controllers/ShortenUrlController.cs new file mode 100644 index 0000000..1129eaf --- /dev/null +++ b/src/ShortenUrl.API/Controllers/ShortenUrlController.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using shortenurl.model.DTOs; +using shortenurl.model.Interfaces.Services; +using shortenurl.model.ViewModels; + +namespace shortenurl.api.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ShortenUrlController : ControllerBase + { + + [HttpPost] + [Route("urls")] + public async Task> CreateShortenUrl(ShortenUrlDTO shortenUrlDTO,[FromServices]IShortenUrlService shortenUrlService) + { + + var code = await shortenUrlService.CreateShortenUrl(shortenUrlDTO); + return Created(string.Empty,code); + } + + [HttpGet("{code}")] + public async Task Get(string code, [FromServices]IShortenUrlService shortenUrlService) + { + var location = await shortenUrlService.GetUrlByCode(code); + return Redirect(location); + } + + + [HttpGet("{code}/stats")] + public async Task> GetCodeStats(string code, [FromServices]IShortenUrlService shortenUrlService) + { + var result = await shortenUrlService.GetStatsByCode(code); + return Ok(result); + } + + } +} diff --git a/src/ShortenUrl.API/Filters/CustomExceptionFilter.cs b/src/ShortenUrl.API/Filters/CustomExceptionFilter.cs new file mode 100644 index 0000000..7f73653 --- /dev/null +++ b/src/ShortenUrl.API/Filters/CustomExceptionFilter.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using shortenurl.model.Exceptions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; + +namespace shortenurl.api.Filters +{ + public class CustomExceptionFilter : ExceptionFilterAttribute + { + public override void OnException(ExceptionContext context) + { + var msg = context.Exception.GetBaseException().Message; + string stack = context.Exception.StackTrace; + + switch (context.Exception.GetType().Name) + { + case "NotFoundException": + context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + break; + case "UnprocessableEntityException": + context.HttpContext.Response.StatusCode = (int)HttpStatusCode.UnprocessableEntity; + break; + case "ConflictException": + context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Conflict; + break; + default: + context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + break; + } + + context.Result = new JsonResult(msg); + base.OnException(context); + } + } +} diff --git a/src/ShortenUrl.API/Program.cs b/src/ShortenUrl.API/Program.cs new file mode 100644 index 0000000..1ab06dd --- /dev/null +++ b/src/ShortenUrl.API/Program.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace shortenurl.api +{ + public class Program + { + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup(); + } +} diff --git a/src/ShortenUrl.API/Properties/launchSettings.json b/src/ShortenUrl.API/Properties/launchSettings.json new file mode 100644 index 0000000..95eff95 --- /dev/null +++ b/src/ShortenUrl.API/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8497", + "sslPort": 44314 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/values", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "shortenurl.api": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/values", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/ShortenUrl.API/Startup.cs b/src/ShortenUrl.API/Startup.cs new file mode 100644 index 0000000..b57b6ba --- /dev/null +++ b/src/ShortenUrl.API/Startup.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using shortenurl.api.Filters; +using shortenurl.model; +using shortenurl.model.Interfaces; +using shortenurl.model.Interfaces.Repositories; +using shortenurl.model.Interfaces.Services; +using shortenurl.model.Repositories; +using shortenurl.model.Services; +using shortenurl.model.Settings; + +namespace shortenurl.api +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddMvc( + options => + options.Filters.Add(new CustomExceptionFilter()) + ) + .SetCompatibilityVersion(CompatibilityVersion.Version_2_1) + .AddJsonOptions(options => + { + //Set date configurations + options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc; + options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + + }); + + + var connectionStringsSection = Configuration.GetSection("ConnectionStrings"); + services.Configure(connectionStringsSection); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseMvc(); + } + } +} diff --git a/src/ShortenUrl.API/appsettings.Development.json b/src/ShortenUrl.API/appsettings.Development.json new file mode 100644 index 0000000..e203e94 --- /dev/null +++ b/src/ShortenUrl.API/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/ShortenUrl.API/appsettings.json b/src/ShortenUrl.API/appsettings.json new file mode 100644 index 0000000..df77fbf --- /dev/null +++ b/src/ShortenUrl.API/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=SHORTEN_URL2;Integrated Security=True;" + } +} diff --git a/src/ShortenUrl.API/shortenurl.api.csproj b/src/ShortenUrl.API/shortenurl.api.csproj new file mode 100644 index 0000000..83b5217 --- /dev/null +++ b/src/ShortenUrl.API/shortenurl.api.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp2.1 + + + + + + + + + + + + + + + + diff --git a/src/ShortenUrl.sln b/src/ShortenUrl.sln new file mode 100644 index 0000000..e966b99 --- /dev/null +++ b/src/ShortenUrl.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.28307.489 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "shortenurl.api", "ShortenUrl.API\shortenurl.api.csproj", "{DBAF0C7F-4D0A-4267-A5D6-32BB25C27E9E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "shortenurl.model", "shortenurl.model\shortenurl.model.csproj", "{FA20AC8E-8930-4BA3-86FE-A4BEAECA283B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shortenurl.unit.test", "shortenurl.unit.test\shortenurl.unit.test.csproj", "{C460CBD3-D275-4A5C-8DA4-50DB84E83994}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DBAF0C7F-4D0A-4267-A5D6-32BB25C27E9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBAF0C7F-4D0A-4267-A5D6-32BB25C27E9E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBAF0C7F-4D0A-4267-A5D6-32BB25C27E9E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBAF0C7F-4D0A-4267-A5D6-32BB25C27E9E}.Release|Any CPU.Build.0 = Release|Any CPU + {FA20AC8E-8930-4BA3-86FE-A4BEAECA283B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA20AC8E-8930-4BA3-86FE-A4BEAECA283B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA20AC8E-8930-4BA3-86FE-A4BEAECA283B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA20AC8E-8930-4BA3-86FE-A4BEAECA283B}.Release|Any CPU.Build.0 = Release|Any CPU + {C460CBD3-D275-4A5C-8DA4-50DB84E83994}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C460CBD3-D275-4A5C-8DA4-50DB84E83994}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C460CBD3-D275-4A5C-8DA4-50DB84E83994}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C460CBD3-D275-4A5C-8DA4-50DB84E83994}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {258CE533-A4D3-4752-89F1-7CBD40C131FB} + EndGlobalSection +EndGlobal diff --git a/src/Shortenurl.Model/DTOs/ShortenUrlDTO.cs b/src/Shortenurl.Model/DTOs/ShortenUrlDTO.cs new file mode 100644 index 0000000..853a968 --- /dev/null +++ b/src/Shortenurl.Model/DTOs/ShortenUrlDTO.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace shortenurl.model.DTOs +{ + public class ShortenUrlDTO + { + [Required] + [Url] + public string Url { get; set; } + + public string Code { get; set; } + } +} diff --git a/src/Shortenurl.Model/Entities/ShortenUrl.cs b/src/Shortenurl.Model/Entities/ShortenUrl.cs new file mode 100644 index 0000000..47d4eb5 --- /dev/null +++ b/src/Shortenurl.Model/Entities/ShortenUrl.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.Entities +{ + public class ShortenUrl + { + public int ShortUrlId { get; set; } + + public string Code { get; set; } + + public string OriginalUrl { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? LastUsage { get; set; } + + public int UsageCount { get; set; } + + + } +} diff --git a/src/Shortenurl.Model/Exceptions/ConflictException.cs b/src/Shortenurl.Model/Exceptions/ConflictException.cs new file mode 100644 index 0000000..b491d15 --- /dev/null +++ b/src/Shortenurl.Model/Exceptions/ConflictException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.Exceptions +{ + public class ConflictException : Exception + { + public ConflictException(string message) : base(message) + { + + } + } +} diff --git a/src/Shortenurl.Model/Exceptions/NotFoundException.cs b/src/Shortenurl.Model/Exceptions/NotFoundException.cs new file mode 100644 index 0000000..cfc4c00 --- /dev/null +++ b/src/Shortenurl.Model/Exceptions/NotFoundException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.Exceptions +{ + public class NotFoundException: Exception + { + public NotFoundException(string message) : base(message) + { + + } + } +} diff --git a/src/Shortenurl.Model/Exceptions/UnprocessableEntityException.cs b/src/Shortenurl.Model/Exceptions/UnprocessableEntityException.cs new file mode 100644 index 0000000..69b4b4a --- /dev/null +++ b/src/Shortenurl.Model/Exceptions/UnprocessableEntityException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.Exceptions +{ + public class UnprocessableEntityException: Exception + { + public UnprocessableEntityException(string message) : base(message) + { + + } + } +} diff --git a/src/Shortenurl.Model/Interfaces/ISqlDataAccess.cs b/src/Shortenurl.Model/Interfaces/ISqlDataAccess.cs new file mode 100644 index 0000000..7dd30f9 --- /dev/null +++ b/src/Shortenurl.Model/Interfaces/ISqlDataAccess.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace shortenurl.model.Interfaces +{ + public interface ISqlDataAccess + { + Task Execute(string procedureName, Dictionary listParams); + Task GetOne(string procedureName, Dictionary listParams); + } +} diff --git a/src/Shortenurl.Model/Interfaces/Repositories/IShortenUrlRepository.cs b/src/Shortenurl.Model/Interfaces/Repositories/IShortenUrlRepository.cs new file mode 100644 index 0000000..88af430 --- /dev/null +++ b/src/Shortenurl.Model/Interfaces/Repositories/IShortenUrlRepository.cs @@ -0,0 +1,19 @@ +using shortenurl.model.DTOs; +using shortenurl.model.Entities; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace shortenurl.model.Interfaces.Repositories +{ + public interface IShortenUrlRepository + { + Task GetShortenUrl(string Code); + + Task InsertShortenUrl(ShortenUrlDTO shortenUrlDTO); + + Task UpdateUsage(int shortUrlId); + + } +} diff --git a/src/Shortenurl.Model/Interfaces/Services/ICodeService.cs b/src/Shortenurl.Model/Interfaces/Services/ICodeService.cs new file mode 100644 index 0000000..b0a52ea --- /dev/null +++ b/src/Shortenurl.Model/Interfaces/Services/ICodeService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.Interfaces.Services +{ + public interface ICodeService + { + bool IsCodeValid(string Code); + string GenerateCode(int length, Random random); + } +} diff --git a/src/Shortenurl.Model/Interfaces/Services/IShortenUrlService.cs b/src/Shortenurl.Model/Interfaces/Services/IShortenUrlService.cs new file mode 100644 index 0000000..2e60d01 --- /dev/null +++ b/src/Shortenurl.Model/Interfaces/Services/IShortenUrlService.cs @@ -0,0 +1,19 @@ +using shortenurl.model.DTOs; +using shortenurl.model.ViewModels; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace shortenurl.model.Interfaces.Services +{ + public interface IShortenUrlService + { + Task CreateShortenUrl(ShortenUrlDTO shortenUrlDTO); + + Task GetUrlByCode(string code); + + + Task GetStatsByCode(string code); + } +} diff --git a/src/Shortenurl.Model/Repositories/ShortenUrlRepository.cs b/src/Shortenurl.Model/Repositories/ShortenUrlRepository.cs new file mode 100644 index 0000000..b1bf25f --- /dev/null +++ b/src/Shortenurl.Model/Repositories/ShortenUrlRepository.cs @@ -0,0 +1,50 @@ +using shortenurl.model.DTOs; +using shortenurl.model.Entities; +using shortenurl.model.Interfaces; +using shortenurl.model.Interfaces.Repositories; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace shortenurl.model.Repositories +{ + public class ShortenUrlRepository : IShortenUrlRepository + { + + private readonly ISqlDataAccess _sqlDataAccess; + public ShortenUrlRepository(ISqlDataAccess sqlDataAccess) + { + _sqlDataAccess = sqlDataAccess; + } + + public async Task GetShortenUrl(string code) + { + var parameters = new Dictionary + { + { "Code", code }, + }; + return await _sqlDataAccess.GetOne("uspGetShortUrl", parameters); + } + + public async Task InsertShortenUrl(ShortenUrlDTO shortenUrlDTO) + { + var parameters = new Dictionary + { + { "Code", shortenUrlDTO.Code }, + { "OriginalUrl", shortenUrlDTO.Url } + }; + + await _sqlDataAccess.Execute("uspInsertShortUrl", parameters); + } + + public async Task UpdateUsage(int shortUrlId) + { + var parameters = new Dictionary + { + { "ShortUrlId", shortUrlId }, + }; + await _sqlDataAccess.Execute("uspUpdateUsage", parameters); + } + } +} diff --git a/src/Shortenurl.Model/Services/CodeService.cs b/src/Shortenurl.Model/Services/CodeService.cs new file mode 100644 index 0000000..3da85fe --- /dev/null +++ b/src/Shortenurl.Model/Services/CodeService.cs @@ -0,0 +1,27 @@ +using shortenurl.model.Interfaces.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace shortenurl.model.Services +{ + public class CodeService : ICodeService + { + public string GenerateCode(int length, Random random) + { + string allowedCharacters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + StringBuilder result = new StringBuilder(length); + for (int i = 0; i < length; i++) + { + result.Append(allowedCharacters[random.Next(allowedCharacters.Length)]); + } + return result.ToString(); + } + + public bool IsCodeValid(string code) + { + return (code.All(char.IsLetterOrDigit) && code.Length == 6); + } + } +} diff --git a/src/Shortenurl.Model/Services/ShortenUrlService.cs b/src/Shortenurl.Model/Services/ShortenUrlService.cs new file mode 100644 index 0000000..a83ecb7 --- /dev/null +++ b/src/Shortenurl.Model/Services/ShortenUrlService.cs @@ -0,0 +1,74 @@ +using shortenurl.model.DTOs; +using shortenurl.model.Exceptions; +using shortenurl.model.Interfaces.Repositories; +using shortenurl.model.Interfaces.Services; +using shortenurl.model.ViewModels; +using System; +using System.Threading.Tasks; + +namespace shortenurl.model.Services +{ + public class ShortenUrlService : IShortenUrlService + { + private readonly IShortenUrlRepository _shortenUrlRepository; + private readonly ICodeService _codeService; + public ShortenUrlService(IShortenUrlRepository shortenUrlRepository, ICodeService codeService) + { + _shortenUrlRepository = shortenUrlRepository; + _codeService = codeService; + } + + public async Task CreateShortenUrl(ShortenUrlDTO shortenUrlDTO) + { + if (string.IsNullOrEmpty(shortenUrlDTO.Code)) + { + shortenUrlDTO.Code = _codeService.GenerateCode(6, new Random()); + } + + if (_codeService.IsCodeValid(shortenUrlDTO.Code)) + { + + var shortenUrl = await _shortenUrlRepository.GetShortenUrl(shortenUrlDTO.Code); + + if (shortenUrl != null) + { + throw new ConflictException("Code in Use"); + } + + + await _shortenUrlRepository.InsertShortenUrl(shortenUrlDTO); + return new ShortUrlCreatedViewModel { Code = shortenUrlDTO.Code }; + } + else + { + throw new UnprocessableEntityException("Code should be alphanumeric and 6 chars long"); + } + + } + + public async Task GetUrlByCode(string code) + { + var shortenUrl = await _shortenUrlRepository.GetShortenUrl(code); + + if (shortenUrl != null) + { + await _shortenUrlRepository.UpdateUsage(shortenUrl.ShortUrlId); + return shortenUrl.OriginalUrl; + } + else + throw new NotFoundException("Code Not Found"); + } + + public async Task GetStatsByCode(string code) + { + var shortenUrl = await _shortenUrlRepository.GetShortenUrl(code); + + if (shortenUrl != null) + { + return new UrlStatsViewModel { CreatedAt = shortenUrl.CreatedAt, LastUsage = shortenUrl.LastUsage, UsageCount = shortenUrl.UsageCount }; + } + else + throw new NotFoundException("Code Not Found"); + } + } +} diff --git a/src/Shortenurl.Model/Settings/ConnectionStrings.cs b/src/Shortenurl.Model/Settings/ConnectionStrings.cs new file mode 100644 index 0000000..f89d61f --- /dev/null +++ b/src/Shortenurl.Model/Settings/ConnectionStrings.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.Settings +{ + public class ConnectionStrings + { + public string DefaultConnection { get; set; } + } +} diff --git a/src/Shortenurl.Model/SqlDataAccess.cs b/src/Shortenurl.Model/SqlDataAccess.cs new file mode 100644 index 0000000..d430ac6 --- /dev/null +++ b/src/Shortenurl.Model/SqlDataAccess.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Options; +using shortenurl.model.Interfaces; +using shortenurl.model.Settings; +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Data; +using System.Data.SqlClient; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace shortenurl.model +{ + public class SqlDataAccess : ISqlDataAccess + { + private readonly ConnectionStrings _connString; + public SqlDataAccess(IOptions connStrings) + { + _connString = connStrings.Value; + var conn = _connString.DefaultConnection; + if (string.IsNullOrEmpty(conn)) + throw new Exception("The connection string is empty. " + + "Please add an entry in the settings.json file with the key 'DefaultConnection' containing the connection string. " + + "Or set the connection string on 'connectionKey' parameter"); + + + + } + + private async Task NewConnection() + { + var conn = new SqlConnection(_connString.DefaultConnection); + await conn.OpenAsync(); + return conn; + } + + + private void SetParams(SqlCommand cmd, Dictionary listParams) + { + foreach (var param in listParams) + { + cmd.Parameters.Add(new SqlParameter(param.Key, param.Value)); + + } + } + + private T DataReaderMapToObject(IDataReader dr) + { + T obj = default(T); + + var columNames = new List(); + + for (int i = 0; i < dr.FieldCount; i++) + { + columNames.Add(dr.GetName(i)); + } + + if (dr.Read()) + { + obj = Activator.CreateInstance(); + foreach (PropertyInfo prop in obj.GetType().GetProperties()) + { + + if (columNames.Contains(prop.Name) && !object.Equals(dr[prop.Name], DBNull.Value)) + { + prop.SetValue(obj, dr[prop.Name], null); + } + + } + } + return obj; + } + + + public async Task Execute(string procedureName, Dictionary listParams) + { + using (var conn = await NewConnection()) + { + using (var cmd = new SqlCommand(procedureName, conn)) + { + + cmd.CommandType = CommandType.StoredProcedure; + cmd.CommandTimeout = 0; + SetParams(cmd, listParams); + await cmd.ExecuteNonQueryAsync(); + + } + } + } + + + public async Task GetOne(string procedureName, Dictionary listParams) + { + T mappedObj; + + using (var conn = await NewConnection()) + { + using (var cmd = new SqlCommand(procedureName, conn)) + { + cmd.CommandType = CommandType.StoredProcedure; + + SetParams(cmd, listParams); + + + var reader = cmd.ExecuteReader(); + + mappedObj = DataReaderMapToObject(reader); + } + + } + return mappedObj; + } + + + } +} diff --git a/src/Shortenurl.Model/ViewModels/ShortUrlCreatedViewModel.cs b/src/Shortenurl.Model/ViewModels/ShortUrlCreatedViewModel.cs new file mode 100644 index 0000000..01ca818 --- /dev/null +++ b/src/Shortenurl.Model/ViewModels/ShortUrlCreatedViewModel.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.ViewModels +{ + public class ShortUrlCreatedViewModel + { + public string Code { get; set; } + } +} diff --git a/src/Shortenurl.Model/ViewModels/UrlStatsViewModel.cs b/src/Shortenurl.Model/ViewModels/UrlStatsViewModel.cs new file mode 100644 index 0000000..3e7e112 --- /dev/null +++ b/src/Shortenurl.Model/ViewModels/UrlStatsViewModel.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace shortenurl.model.ViewModels +{ + public class UrlStatsViewModel + { + public DateTime CreatedAt { get; set; } + + public DateTime? LastUsage { get; set; } + + public int UsageCount { get; set; } + } +} diff --git a/src/Shortenurl.Model/shortenurl.model.csproj b/src/Shortenurl.Model/shortenurl.model.csproj new file mode 100644 index 0000000..62112f9 --- /dev/null +++ b/src/Shortenurl.Model/shortenurl.model.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + + + + + + + + + + + C:\Program Files\dotnet\sdk\NuGetFallbackFolder\microsoft.extensions.options\2.1.1\lib\netstandard2.0\Microsoft.Extensions.Options.dll + + + + diff --git a/src/shortenurl.unit.test/Services/ShortenUrlService.cs b/src/shortenurl.unit.test/Services/ShortenUrlService.cs new file mode 100644 index 0000000..e03b5dc --- /dev/null +++ b/src/shortenurl.unit.test/Services/ShortenUrlService.cs @@ -0,0 +1,153 @@ +using Moq; +using Newtonsoft.Json; +using shortenurl.model.DTOs; +using shortenurl.model.Entities; +using shortenurl.model.Exceptions; +using shortenurl.model.Interfaces.Repositories; +using shortenurl.model.Services; +using shortenurl.model.ViewModels; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace shortenurl.unit.test +{ + public class ShortenUrlServiceTest + { + [Theory] + [InlineData("AbC123", "http://google.com")] + [InlineData("", "http://google.com")] + [InlineData(null, "http://google.com")] + public async void CreateShortenUrlWithValidCodeOrEmpty(string code, string url) + { + var shortenUrlDTO = new ShortenUrlDTO { Code = code, Url = url }; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(null)); + ShortenUrlRepostory.Setup(p => p.InsertShortenUrl(It.IsAny())); + + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + var result = await shortenUrlService.CreateShortenUrl(shortenUrlDTO); + + if (string.IsNullOrEmpty(code)) + { + Assert.NotEmpty(result.Code); + } + else + { + Assert.Equal(code, result.Code); + } + + Assert.Equal(6, result.Code.Length); + } + + + + [Theory] + [InlineData("AbC12", "http://google.com")] + [InlineData("AbC!23", "http://google.com")] + public async void CreateShortenUrlInvalidCode(string code, string url) + { + var shortenUrlDTO = new ShortenUrlDTO { Code = code, Url = url }; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(null)); + ShortenUrlRepostory.Setup(p => p.InsertShortenUrl(It.IsAny())); + + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + + await Assert.ThrowsAsync(() => shortenUrlService.CreateShortenUrl(shortenUrlDTO)); + + } + + [Theory] + [InlineData("AbC123", "http://google.com")] + public async void CreateShortenUrlExistingCode(string code, string url) + { + var shortenUrlDTO = new ShortenUrlDTO { Code = code, Url = url }; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(new ShortenUrl())); + ShortenUrlRepostory.Setup(p => p.InsertShortenUrl(It.IsAny())); + + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + + await Assert.ThrowsAsync(() => shortenUrlService.CreateShortenUrl(shortenUrlDTO)); + + } + + [Fact] + public async void GetUrlByCodeValid() + { + var code = "ABC123"; + var url = "http://google.com"; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(new ShortenUrl { Code = code, OriginalUrl = url })); + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + var result = await shortenUrlService.GetUrlByCode(code); + + Assert.Equal(url, result); + + } + + + [Fact] + public async void GetUrlByCodeNotFound() + { + var code = "ABC123"; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(null)); + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + await Assert.ThrowsAsync(() => shortenUrlService.GetUrlByCode(code)); + } + + + + [Fact] + public async void GetStatsByCodeValid() + { + var createdAt = DateTime.UtcNow.AddDays(-5); + var lastUsage = DateTime.UtcNow; + + var shortenUrl = new ShortenUrl { Code = "ABC123", CreatedAt = createdAt, LastUsage = lastUsage, ShortUrlId = 1, OriginalUrl = "http://google.com", UsageCount = 3 }; + var expectedStats = new UrlStatsViewModel { CreatedAt = createdAt, LastUsage = lastUsage, UsageCount = 3 }; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(shortenUrl)); + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + var result = await shortenUrlService.GetStatsByCode("ABC123"); + + var expected = JsonConvert.SerializeObject(expectedStats); + var resultStats = JsonConvert.SerializeObject(result); + + Assert.Equal(expected, resultStats); + } + + + [Fact] + public async void GetStatsByCodeNotFound() + { + var code = "ABC123"; + + var ShortenUrlRepostory = new Mock(); + ShortenUrlRepostory.Setup(p => p.GetShortenUrl(It.IsAny())).Returns(Task.FromResult(null)); + var shortenUrlService = new ShortenUrlService(ShortenUrlRepostory.Object, new CodeService()); + + await Assert.ThrowsAsync(() => shortenUrlService.GetStatsByCode(code)); + } + + + + + + + } +} diff --git a/src/shortenurl.unit.test/shortenurl.unit.test.csproj b/src/shortenurl.unit.test/shortenurl.unit.test.csproj new file mode 100644 index 0000000..b49d2f9 --- /dev/null +++ b/src/shortenurl.unit.test/shortenurl.unit.test.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp2.1 + + false + + + + + + + + + + + + + +