From 1ec440212dcda7cbd225d751944b8c322d61542d Mon Sep 17 00:00:00 2001 From: ozkank Date: Tue, 2 Jul 2024 16:33:22 +0300 Subject: [PATCH] initial setup --- .dockerignore | 25 ++++ .../Features/RedirectUrl.cs | 135 ++++++++++-------- .../Features/ShortenUrl.cs | 114 ++++++++------- UrlShortener.ApiService/Program.cs | 8 +- UrlShortener.ApiService/Shared/Error.cs | 11 ++ UrlShortener.ApiService/Shared/Result.cs | 39 +++++ UrlShortener.ApiService/Shared/ResultT.cs | 17 +++ .../UrlShortener.ApiService.csproj | 9 +- UrlShortener.AppHost/Program.cs | 6 +- .../UrlShortener.AppHost.csproj | 1 + .../UrlShortener.ServiceDefaults.csproj | 2 +- UrlShortener.sln | 11 +- docker-compose.dcproj | 18 +++ docker-compose.override.yml | 13 ++ docker-compose.yml | 22 +++ 15 files changed, 315 insertions(+), 116 deletions(-) create mode 100644 .dockerignore create mode 100644 UrlShortener.ApiService/Shared/Error.cs create mode 100644 UrlShortener.ApiService/Shared/Result.cs create mode 100644 UrlShortener.ApiService/Shared/ResultT.cs create mode 100644 docker-compose.dcproj create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3729ff0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/UrlShortener.ApiService/Features/RedirectUrl.cs b/UrlShortener.ApiService/Features/RedirectUrl.cs index 6daa4c3..06d2c1e 100644 --- a/UrlShortener.ApiService/Features/RedirectUrl.cs +++ b/UrlShortener.ApiService/Features/RedirectUrl.cs @@ -1,56 +1,79 @@ -using Carter; -using MediatR; -using Microsoft.EntityFrameworkCore; -using UrlShortener.ApiService.Infrastructure.Database; - -namespace UrlShortener.ApiService.Features -{ - public class RedirectUrlQuery : IRequest - { - public string Code { get; set; } - } - - internal sealed class RedirectUrlQueryHandler : - IRequestHandler - { - private readonly ApplicationDbContext _context; - public const int NumberOfCharsInShortLink = 7; - private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - - private readonly Random _random = new(); - - public RedirectUrlQueryHandler(ApplicationDbContext context) - { - _context = context; - } - - public async Task Handle(RedirectUrlQuery request, CancellationToken cancellationToken) - { - var shortenedUrl = await _context - .ShortenedUrls - .FirstOrDefaultAsync(s => s.Code == request.Code); - - if (shortenedUrl is null) - { - //return Results.NotFound(); - } - - return shortenedUrl.ShortUrl; - } - } - - - public class RedirectUrlEndpoint : ICarterModule - { - public void AddRoutes(IEndpointRouteBuilder app) - { - app.MapGet("api/{code}", async (String code, ISender sender) => - { - var query = new RedirectUrlQuery { Code = code }; - var result = await sender.Send(query); - - return Results.Ok(result); - }); - } - } -} +//using Azure.Core; +//using Carter; +//using FluentValidation; +//using MediatR; +//using Microsoft.EntityFrameworkCore; +//using UrlShortener.ApiService.Infrastructure.Database; +//using UrlShortener.ApiService.Shared; + +//namespace UrlShortener.ApiService.Features +//{ +// public class RedirectUrl +// { +// public class Query : IRequest> +// { +// public string Code { get; set; } = string.Empty; +// } + +// internal sealed class Handler : IRequestHandler> +// { +// private readonly ApplicationDbContext _context; +// public const int NumberOfCharsInShortLink = 7; +// private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +// private readonly Random _random = new(); + +// public Handler(ApplicationDbContext context) +// { +// _context = context; +// } + +// public async Task> Handle(Query request, CancellationToken cancellationToken) +// { +// var item = await _context +// .ShortenedUrls +// .Where(s => s.Code == request.Code) +// .Select(x => new RedirectUrlResponse +// { +// LongUrl = x.LongUrl, +// }) +// .FirstOrDefaultAsync(cancellationToken); + +// if (item is null) +// { +// return Result.Failure(new Error( +// "RedirectUrlResponse.Null", +// "The longUrl with the specified shortUrl was not found")); +// } + +// return item; +// } +// } + +// } + +// public class RedirectUrlEndpoint : ICarterModule +// { +// public void AddRoutes(IEndpointRouteBuilder app) +// { +// app.MapGet("api/redirect/{shortUrl}", async (string shortUrl, ISender sender) => +// { +// var query = new RedirectUrl.Query { Code = shortUrl }; + +// var result = await sender.Send(query); + +// if (result.IsFailure) +// { +// return Results.NotFound(result.Error); +// } + +// return Results.Ok(result.Value); +// }); +// } +// } + +// public class RedirectUrlResponse +// { +// public string LongUrl { get; set; } = string.Empty; +// } +//} diff --git a/UrlShortener.ApiService/Features/ShortenUrl.cs b/UrlShortener.ApiService/Features/ShortenUrl.cs index e32ca5b..8359864 100644 --- a/UrlShortener.ApiService/Features/ShortenUrl.cs +++ b/UrlShortener.ApiService/Features/ShortenUrl.cs @@ -6,73 +6,86 @@ using System; using UrlShortener.ApiService.Domain; using UrlShortener.ApiService.Infrastructure.Database; +using UrlShortener.ApiService.Shared; namespace UrlShortener.ApiService.Features { - public class ShortenUrlQuery : IRequest + public class ShortenUrl { - public string LongUrl { get; set; } - } - - - internal sealed class ShortenUrlQueryHandler : - IRequestHandler - { - private readonly ApplicationDbContext _context; - public const int NumberOfCharsInShortLink = 7; - private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - - private readonly Random _random = new(); - - public ShortenUrlQueryHandler(ApplicationDbContext context) + public class Command : IRequest> { - _context = context; + public string LongUrl { get; set; } } - public async Task Handle(ShortenUrlQuery request, CancellationToken cancellationToken) + public class Validator : AbstractValidator { - if (!Uri.TryCreate(request.LongUrl, UriKind.Absolute, out _)) + public Validator() { - throw new Exception("The specified URL is invalid."); - //return Results.BadRequest("The specified URL is invalid."); + RuleFor(c => c.LongUrl).NotEmpty(); } + } - var code = await GenerateUniqueCode(); + internal sealed class Handler : IRequestHandler> + { + private readonly ApplicationDbContext _dbContext; + private readonly IValidator _validator; + public const int NumberOfCharsInShortLink = 7; + private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private readonly Random _random = new(); - var shortenedUrl = new ShortenedUrl + public Handler(ApplicationDbContext dbContext, IValidator validator) { - Id = Guid.NewGuid(), - LongUrl = request.LongUrl, - Code = code, - //ShortUrl = $"{_context.Request.Scheme}://{_context.Request.Host}/api/{code}", - CreatedOnUtc = DateTime.UtcNow - }; + _dbContext = dbContext; + _validator = validator; + } - _context.ShortenedUrls.Add(shortenedUrl); + public async Task> Handle(Command request, CancellationToken cancellationToken) + { + var validationResult = _validator.Validate(request); + if (!validationResult.IsValid) + { + return Result.Failure(new Error( + "ShortenUrl.Validation", + validationResult.ToString())); + } - await _context.SaveChangesAsync(); + var code = await GenerateUniqueCode(); - return shortenedUrl.ShortUrl; - } + var shortenedUrl = new ShortenedUrl + { + Id = Guid.NewGuid(), + LongUrl = request.LongUrl, + Code = code, + //ShortUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/{code}", + CreatedOnUtc = DateTime.UtcNow + }; - public async Task GenerateUniqueCode() - { - var codeChars = new char[NumberOfCharsInShortLink]; + _dbContext.ShortenedUrls.Add(shortenedUrl); + + await _dbContext.SaveChangesAsync(); - while (true) + return shortenedUrl.ShortUrl; + } + + private async Task GenerateUniqueCode() { - for (var i = 0; i < NumberOfCharsInShortLink; i++) + var codeChars = new char[NumberOfCharsInShortLink]; + + while (true) { - var randomIndex = _random.Next(Alphabet.Length - 1); + for (var i = 0; i < NumberOfCharsInShortLink; i++) + { + var randomIndex = _random.Next(Alphabet.Length - 1); - codeChars[i] = Alphabet[randomIndex]; - } + codeChars[i] = Alphabet[randomIndex]; + } - var code = new string(codeChars); + var code = new string(codeChars); - if (!await _context.ShortenedUrls.AnyAsync(s => s.Code == code)) - { - return code; + if (!await _dbContext.ShortenedUrls.AnyAsync(s => s.Code == code)) + { + return code; + } } } } @@ -82,13 +95,18 @@ public class ShortenUrlEndpoint : ICarterModule { public void AddRoutes(IEndpointRouteBuilder app) { - app.MapGet("api/shorten/{longUrl}", async (String longUrl, ISender sender) => + app.MapPost("api/articles", async (ShortenUrl.Command request, ISender sender) => { - var query = new ShortenUrlQuery { LongUrl = longUrl }; - var result = await sender.Send(query); + var result = await sender.Send(request); + + if (result.IsFailure) + { + return Results.BadRequest(result.Error); + } - return Results.Ok(result); + return Results.Ok(result.Value); }); } } + } diff --git a/UrlShortener.ApiService/Program.cs b/UrlShortener.ApiService/Program.cs index e6c373a..a8ffadf 100644 --- a/UrlShortener.ApiService/Program.cs +++ b/UrlShortener.ApiService/Program.cs @@ -6,6 +6,8 @@ var builder = WebApplication.CreateBuilder(args); +builder.AddSqlServerDbContext("sqldata"); + // Add service defaults & Aspire components. builder.AddServiceDefaults(); @@ -15,13 +17,13 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddDbContext(o => - o.UseSqlServer(builder.Configuration.GetConnectionString("Database"))); +//builder.Services.AddDbContext(o => +// o.UseSqlServer(builder.Configuration.GetConnectionString("Database"))); builder.Services.AddEndpoints(typeof(Program).Assembly); -//builder.Services.AddMediatR(config => config.RegisterServicesFromAssembly(typeof(Program).Assembly)); +builder.Services.AddMediatR(config => config.RegisterServicesFromAssembly(typeof(Program).Assembly)); builder.Services.AddCarter(); diff --git a/UrlShortener.ApiService/Shared/Error.cs b/UrlShortener.ApiService/Shared/Error.cs new file mode 100644 index 0000000..e7454bb --- /dev/null +++ b/UrlShortener.ApiService/Shared/Error.cs @@ -0,0 +1,11 @@ +namespace UrlShortener.ApiService.Shared +{ + public record Error(string Code, string Message) + { + public static readonly Error None = new(string.Empty, string.Empty); + + public static readonly Error NullValue = new("Error.NullValue", "The specified result value is null."); + + public static readonly Error ConditionNotMet = new("Error.ConditionNotMet", "The specified condition was not met."); + } +} diff --git a/UrlShortener.ApiService/Shared/Result.cs b/UrlShortener.ApiService/Shared/Result.cs new file mode 100644 index 0000000..2d13211 --- /dev/null +++ b/UrlShortener.ApiService/Shared/Result.cs @@ -0,0 +1,39 @@ +namespace UrlShortener.ApiService.Shared +{ + public class Result + { + protected internal Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None) + { + throw new InvalidOperationException(); + } + + if (!isSuccess && error == Error.None) + { + throw new InvalidOperationException(); + } + + IsSuccess = isSuccess; + Error = error; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public Error Error { get; } + + public static Result Success() => new(true, Error.None); + + public static Result Success(TValue value) => new(value, true, Error.None); + + public static Result Failure(Error error) => new(false, error); + + public static Result Failure(Error error) => new(default, false, error); + + public static Result Create(bool condition) => condition ? Success() : Failure(Error.ConditionNotMet); + + public static Result Create(TValue? value) => value is not null ? Success(value) : Failure(Error.NullValue); + } +} diff --git a/UrlShortener.ApiService/Shared/ResultT.cs b/UrlShortener.ApiService/Shared/ResultT.cs new file mode 100644 index 0000000..32b3fb4 --- /dev/null +++ b/UrlShortener.ApiService/Shared/ResultT.cs @@ -0,0 +1,17 @@ +namespace UrlShortener.ApiService.Shared +{ + public class Result : Result + { + private readonly TValue? _value; + + protected internal Result(TValue? value, bool isSuccess, Error error) + : base(isSuccess, error) => + _value = value; + + public TValue Value => IsSuccess + ? _value! + : throw new InvalidOperationException("The value of a failure result can not be accessed."); + + public static implicit operator Result(TValue? value) => Create(value); + } +} diff --git a/UrlShortener.ApiService/UrlShortener.ApiService.csproj b/UrlShortener.ApiService/UrlShortener.ApiService.csproj index 7cd7add..6e34e76 100644 --- a/UrlShortener.ApiService/UrlShortener.ApiService.csproj +++ b/UrlShortener.ApiService/UrlShortener.ApiService.csproj @@ -11,22 +11,19 @@ + - + - - - - diff --git a/UrlShortener.AppHost/Program.cs b/UrlShortener.AppHost/Program.cs index 3b41422..2876f03 100644 --- a/UrlShortener.AppHost/Program.cs +++ b/UrlShortener.AppHost/Program.cs @@ -1,8 +1,12 @@ var builder = DistributedApplication.CreateBuilder(args); +var sql = builder.AddSqlServer("sql") + .AddDatabase("sqldata"); + var cache = builder.AddRedis("cache"); -var apiService = builder.AddProject("apiservice"); +var apiService = builder.AddProject("apiservice") + .WithReference(sql); builder.AddProject("webfrontend") .WithExternalHttpEndpoints() diff --git a/UrlShortener.AppHost/UrlShortener.AppHost.csproj b/UrlShortener.AppHost/UrlShortener.AppHost.csproj index 01c47d0..4d11edb 100644 --- a/UrlShortener.AppHost/UrlShortener.AppHost.csproj +++ b/UrlShortener.AppHost/UrlShortener.AppHost.csproj @@ -17,6 +17,7 @@ + diff --git a/UrlShortener.ServiceDefaults/UrlShortener.ServiceDefaults.csproj b/UrlShortener.ServiceDefaults/UrlShortener.ServiceDefaults.csproj index 51ceffa..2e8b716 100644 --- a/UrlShortener.ServiceDefaults/UrlShortener.ServiceDefaults.csproj +++ b/UrlShortener.ServiceDefaults/UrlShortener.ServiceDefaults.csproj @@ -10,7 +10,7 @@ - + diff --git a/UrlShortener.sln b/UrlShortener.sln index 5134fd9..d589708 100644 --- a/UrlShortener.sln +++ b/UrlShortener.sln @@ -2,7 +2,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.34928.147 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UrlShortener.AppHost", "UrlShortener.AppHost\UrlShortener.AppHost.csproj", "{BF5CA513-FA60-4A43-BB44-644D1C754F52}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UrlShortener.AppHost", "UrlShortener.AppHost\UrlShortener.AppHost.csproj", "{BF5CA513-FA60-4A43-BB44-644D1C754F52}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UrlShortener.ServiceDefaults", "UrlShortener.ServiceDefaults\UrlShortener.ServiceDefaults.csproj", "{8B5DCD3D-E970-45E7-8B40-592290C462ED}" EndProject @@ -12,6 +12,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UrlShortener.Web", "UrlShor EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UrlShortener.Tests", "UrlShortener.Tests\UrlShortener.Tests.csproj", "{BEFDA698-1C15-470B-A3C0-8C799123E70E}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{70431088-1B46-45B1-97B3-EEE6F52F1833}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,6 +44,13 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BF5CA513-FA60-4A43-BB44-644D1C754F52} = {70431088-1B46-45B1-97B3-EEE6F52F1833} + {8B5DCD3D-E970-45E7-8B40-592290C462ED} = {70431088-1B46-45B1-97B3-EEE6F52F1833} + {0ACE5893-40FE-4AB5-BA95-86CCF14E6E88} = {70431088-1B46-45B1-97B3-EEE6F52F1833} + {F22DBFF8-C307-45F5-A2B3-843AB89AA126} = {70431088-1B46-45B1-97B3-EEE6F52F1833} + {BEFDA698-1C15-470B-A3C0-8C799123E70E} = {70431088-1B46-45B1-97B3-EEE6F52F1833} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BE984165-2AC7-4884-BF0C-F07932DDC486} EndGlobalSection diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..a6bb0e4 --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,18 @@ + + + + 2.1 + Linux + 56f62bc3-7646-4559-92eb-499120179413 + LaunchBrowser + {Scheme}://localhost:{ServicePort}/swagger + newsletter-api + + + + docker-compose.yml + + + + + \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..e137bd1 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,13 @@ +version: '3.4' + +services: + newsletter-api: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=https://+:443;http://+:80 + ports: + - "80" + - "5001:443" + volumes: + - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro + - ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e8205bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.4' + +services: + newsletter-api: + image: ${DOCKER_REGISTRY-}newsletter-api + container_name: Newsletter.Api + build: + context: . + dockerfile: Newsletter.Api/Dockerfile + ports: + - "5001:443" + + newsletter-db: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: Newsletter.Db + volumes: + - ./.containers/database:/var/opt/mssql/data + ports: + - "1433:1433" + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "Strong_password_123!"