Compare commits

10 Commits
master ... dev

29 changed files with 846 additions and 0 deletions

4
.editorconfig Normal file
View File

@@ -0,0 +1,4 @@
[*.cs]
# CS8618: Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
dotnet_diagnostic.CS8618.severity = none

View File

@@ -0,0 +1,31 @@
using Diary.Component.Entries.Service;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Diary.Component.Entries
{
[ApiController]
[Route("[controller]")]
public class EntriesController:ControllerBase
{
private readonly IEntryService _entryService;
public EntriesController(IEntryService entryService)
{
_entryService = entryService ?? throw new ArgumentNullException(nameof(entryService));
}
[HttpPost]
public async Task<EntryResource> CreateEntryAsync(CreateEntryResource create)
{
return await _entryService.CreateAsync(create);
}
}
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Diary.Component.Entries.Repository
{
[Table("Entries", Schema = "Diary")]
public class Entry
{
[Key]
public int EnrtyID { get; set; }
[Required]
[Column(TypeName = "date")]
public DateTime Date { get; set; }
[Required]
public DateTime ValidFrom { get; set; } = DateTime.Now;
public DateTime? ValidTo { get; set; }
public string Note { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Diary.Component.Entries.Repository
{
public class EntryConfiguration : IEntityTypeConfiguration<Entry>
{
public void Configure(EntityTypeBuilder<Entry> builder)
{
}
}
}

View File

@@ -0,0 +1,26 @@
using Diary.Data;
namespace Diary.Component.Entries.Repository
{
public interface IEntryRepository
{
Task<Entry> CreateAsync(Entry entry);
}
public class EntryRepository : IEntryRepository
{
private readonly IDiaryDBContext _diaryDbContext;
public EntryRepository(IDiaryDBContext diaryDbContext)
{
_diaryDbContext = diaryDbContext ?? throw new ArgumentNullException(nameof(diaryDbContext));
}
public async Task<Entry> CreateAsync(Entry entry)
{
await _diaryDbContext.Entries.AddAsync(entry);
return entry;
}
}
}

View File

@@ -0,0 +1,15 @@
using AutoMapper;
using Diary.Component.Entries.Repository;
namespace Diary.Component.Entries.Service
{
public class EntryMappings : Profile
{
public EntryMappings()
{
CreateMap<CreateEntryResource, Entry>();
CreateMap<Entry, EntryResource>()
.ForMember(dest => dest.Date, opt => opt.MapFrom(src => src.Date.Date));
}
}
}

View File

@@ -0,0 +1,18 @@
namespace Diary.Component.Entries.Service
{
public class EntryResource
{
public int EnrtyID { get; set; }
public DateOnly Date { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
public string Note { get; set; }
}
public class CreateEntryResource
{
public DateTime Date { get; set; }
public string Note { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
using AutoMapper;
using Diary.Component.Entries.Repository;
using Diary.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Diary.Component.Entries.Service
{
public interface IEntryService
{
Task<EntryResource> CreateAsync(CreateEntryResource createEntryResource);
}
public class EntryService : IEntryService
{
private readonly IEntryRepository _entryRepository;
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
public EntryService(IEntryRepository entryRepository, IMapper mapper, IUnitOfWork unitOfWork)
{
_entryRepository = entryRepository ?? throw new ArgumentNullException(nameof(entryRepository));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public async Task<EntryResource> CreateAsync(CreateEntryResource createEntryResource)
{
Entry entry = _mapper.Map<Entry>(createEntryResource);
await _entryRepository.CreateAsync(entry);
await _unitOfWork.CompleteAsync();
return _mapper.Map<EntryResource>(entry);
}
}
}

33
Data/DiaryDBContext.cs Normal file
View File

@@ -0,0 +1,33 @@
using Diary.Component.Entries.Repository;
using Microsoft.EntityFrameworkCore;
namespace Diary.Data
{
public interface IDiaryDBContext
{
DbSet<Entry> Entries { get; set; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
public class DiaryDBContext : DbContext, IDiaryDBContext
{
public DbSet<Entry> Entries { get; set; }
public DiaryDBContext(DbContextOptions<DiaryDBContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfiguration(new EntryConfiguration());
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
}
}

View File

@@ -0,0 +1,55 @@
// <auto-generated />
using System;
using Diary.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Diary.Data.Migrations
{
[DbContext(typeof(DiaryDBContext))]
[Migration("20220603115417_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1);
modelBuilder.Entity("Diary.Component.Entry.Repository.Entry", b =>
{
b.Property<int>("EnrtyID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("EnrtyID"), 1L, 1);
b.Property<DateTime>("Date")
.HasColumnType("date");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ValidFrom")
.HasColumnType("datetime2");
b.Property<DateTime?>("ValidTo")
.HasColumnType("datetime2");
b.HasKey("EnrtyID");
b.ToTable("Entries", "Diary");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Diary.Data.Migrations
{
public partial class Initial : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "Diary");
migrationBuilder.CreateTable(
name: "Entries",
schema: "Diary",
columns: table => new
{
EnrtyID = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Date = table.Column<DateTime>(type: "date", nullable: false),
ValidFrom = table.Column<DateTime>(type: "datetime2", nullable: false),
ValidTo = table.Column<DateTime>(type: "datetime2", nullable: true),
Note = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Entries", x => x.EnrtyID);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Entries",
schema: "Diary");
}
}
}

View File

@@ -0,0 +1,53 @@
// <auto-generated />
using System;
using Diary.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Diary.Data.Migrations
{
[DbContext(typeof(DiaryDBContext))]
partial class DiaryDBContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1);
modelBuilder.Entity("Diary.Component.Entry.Repository.Entry", b =>
{
b.Property<int>("EnrtyID")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("EnrtyID"), 1L, 1);
b.Property<DateTime>("Date")
.HasColumnType("date");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ValidFrom")
.HasColumnType("datetime2");
b.Property<DateTime?>("ValidTo")
.HasColumnType("datetime2");
b.HasKey("EnrtyID");
b.ToTable("Entries", "Diary");
});
#pragma warning restore 612, 618
}
}
}

23
Data/UnitOfWork.cs Normal file
View File

@@ -0,0 +1,23 @@
namespace Diary.Data
{
public interface IUnitOfWork
{
Task CompleteAsync();
}
public class UnitOfWork:IUnitOfWork
{
private readonly IDiaryDBContext _context;
public UnitOfWork(IDiaryDBContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public async Task CompleteAsync()
{
await _context.SaveChangesAsync();
}
}
}

28
Diary.csproj Normal file
View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="11.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="5.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
</ItemGroup>
</Project>

30
Diary.sln Normal file
View File

@@ -0,0 +1,30 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31825.309
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Diary", "Diary.csproj", "{AC078C62-6705-45A9-A00A-7EDDCF770525}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A852FE41-7660-406A-A6F6-8EDE1F7C1586}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{AC078C62-6705-45A9-A00A-7EDDCF770525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC078C62-6705-45A9-A00A-7EDDCF770525}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AC078C62-6705-45A9-A00A-7EDDCF770525}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC078C62-6705-45A9-A00A-7EDDCF770525}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {19C79751-E69B-41D1-A7CB-9FE404E6EA36}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,17 @@
using Diary.Data;
using Microsoft.EntityFrameworkCore;
namespace Diary.Installers
{
public static class DatabaseInstaller
{
public static IServiceCollection AddDatabase(this IServiceCollection services, IConfigurationRoot config)
{
return services.AddDbContext<DiaryDBContext>(opt =>
{
string connectionString = config.GetConnectionString("Diary");
opt.UseSqlServer(connectionString);
});
}
}
}

View File

@@ -0,0 +1,26 @@
using Diary.Component.Entries.Repository;
using Diary.Component.Entries.Service;
using Diary.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Diary.Installers
{
public static class DependencyInstallers
{
public static IServiceCollection AddDependencies(this IServiceCollection services)
{
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IEntryService, EntryService>();
services.AddScoped<IEntryRepository, EntryRepository>();
return services;
}
}
}

View File

@@ -0,0 +1,12 @@
using Diary.Shared;
namespace Diary.Installers
{
public static class InstallExceptionsMiddleware
{
public static void AddExceptionsMiddleware(this IServiceCollection services)
{
services.AddScoped<ExceptionHandlingMiddleware>();
}
}
}

View File

@@ -0,0 +1,21 @@
using Diary.Shared;
using Microsoft.AspNetCore.Mvc;
namespace Diary.Installers
{
public static class InstallFilters
{
public static void AddFilters(this IServiceCollection services)
{
services
.Configure<ApiBehaviorOptions>(opt =>
{
opt.SuppressModelStateInvalidFilter = true;
})
.AddControllers(opt =>
{
opt.Filters.Add<ValidationFilter>();
});
}
}
}

View File

@@ -0,0 +1,30 @@
using ILogger = Serilog.ILogger;
namespace Diary.Installers
{
public class LoggingStartService : IHostedService
{
private readonly ILogger _log;
private readonly string _serviceName;
public ILogger Logger { get; }
public LoggingStartService(ILogger logger, string serviceName)
{
_log = logger.ForContext<LoggingStartService>();
_serviceName = serviceName ?? "API";
}
public Task StartAsync(CancellationToken cancellationToken)
{
_log.Information("{ServiceName} service started", _serviceName);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_log.Information("{ServiceName} service stopped", _serviceName);
return Task.CompletedTask;
}
}
}

1
Log.txt Normal file
View File

@@ -0,0 +1 @@
2022-06-04 14:39:36.631 +01:00 [INF] start

106
Program.cs Normal file
View File

@@ -0,0 +1,106 @@
using Diary.Data;
using Diary.Installers;
using Diary.Shared;
using Serilog;
using Serilog.Events;
using System.Reflection;
/*
* Settings - Done
* Logging - Done
* Database - Done
* Automapper - Done
* Cors
* Views
* Swagger - Done
* Auth
* Exception Middleware - Done
* Validation Filter - Done
* */
//+Setup Logger
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.WriteTo.Console()
.WriteTo.Seq("http://seq.lan:5341", apiKey: "Jtfj82GQmcKTAh1kW3zI")
.WriteTo.File("Logs/Log.txt")
.Enrich.FromLogContext()
.CreateLogger();
Log.Information("Starting up");
var builder = WebApplication.CreateBuilder(args);
//---Y
//Add Serilog
builder.Host.UseSerilog(Log.Logger);
builder.Services.AddSingleton<IHostedService>(new LoggingStartService(Log.Logger, Path.GetFileNameWithoutExtension(Assembly.GetExecutingAssembly().ManifestModule.ScopeName)));
//---Y
//Database
builder.Services.AddDatabase(builder.Configuration);
builder.Services.AddScoped<IDiaryDBContext, DiaryDBContext>();
//---y
//Dependancies
builder.Services.AddDependencies();
//---Y
//Automapper
builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly());
builder.Services.AddControllers();
builder.Services.AddExceptionsMiddleware();
builder.Services.AddFilters();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
//---R
//+Build
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(opt =>
{
opt.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
opt.RoutePrefix = String.Empty;
}
);
}
app.UseSerilogRequestLogging();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.UseMiddleware<ExceptionHandlingMiddleware>();
try
{
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Unhandled exception");
}
finally
{
Log.Information("Shut down complete");
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Diary": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "https://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

22
Shared/AppException.cs Normal file
View File

@@ -0,0 +1,22 @@
using System.Net;
namespace Diary.Shared
{
public class AppException : Exception
{
public string Title { get; }
public HttpStatusCode StatusCode { get; }
public AppException(HttpStatusCode statusCode, string title, string message) : base(message)
{
StatusCode = statusCode;
Title = title;
}
public AppException(HttpStatusCode statusCode, string title, string message, Exception innerException) : base(message, innerException)
{
StatusCode = statusCode;
Title = title;
}
}
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json;
namespace Diary.Shared
{
public class ExceptionDetails
{
public string? Message { get; set; }
public string? Title { get; set; }
public override string ToString()
{
var jsonSerializerSettings = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return JsonSerializer.Serialize(this, jsonSerializerSettings);
}
}
public class ValidationExceptionDetails : ExceptionDetails
{
public List<ValidationProblemDescriptor> ModelState { get; set; } = new();
}
public class ValidationProblemDescriptor
{
public string? Property { get; set; }
public string[]? Errors { get; set; }
}
}

View File

@@ -0,0 +1,60 @@

namespace Diary.Shared
{
public partial class ExceptionHandlingMiddleware : IMiddleware
{
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(ILogger<ExceptionHandlingMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception e)
{
_logger.LogError(e, "Error received from ExceptionHandlingMiddleware");
await HandleExceptionAsync(context, e);
}
}
private static async Task HandleExceptionAsync(HttpContext httpContext, Exception exception)
{
ExceptionDetails exceptionDetails;
httpContext.Response.ContentType = "application/json";
if (exception.InnerException is AppException)
{
exception = exception.InnerException;
}
if (exception is AppException applicationException)
{
httpContext.Response.StatusCode = (int)applicationException.StatusCode;
exceptionDetails = new()
{
Message = applicationException.Message,
Title = applicationException.Title
};
}
else
{
httpContext.Response.StatusCode = 500;
exceptionDetails = new()
{
Message = exception.Message,
Title = "API Error"
};
}
await httpContext.Response.WriteAsync(exceptionDetails.ToString());
}
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Diary.Shared
{
public class ValidationFilter : IAsyncActionFilter
{
public const string OverviewTitle = "The request is invalid.";
public const string OverviewMessage = "The request sent data that is not correct for the request.";
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ModelState.IsValid)
{
await next();
}
else
{
ValidationExceptionDetails errorObj = new()
{
Title = OverviewTitle,
Message = OverviewMessage,
ModelState = GetErrors(context.ModelState)
};
context.Result = new BadRequestObjectResult(errorObj);
}
}
private static List<ValidationProblemDescriptor> GetErrors(ModelStateDictionary modelState)
{
List<ValidationProblemDescriptor> errors = new();
return modelState.Where(ms => ms.Value?.Errors.Count > 0)
.Select(x => new ValidationProblemDescriptor
{
Property = x.Key,
Errors = x.Value?.Errors.Select(e => e.ErrorMessage).ToArray()
})
.ToList();
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

16
appsettings.json Normal file
View File

@@ -0,0 +1,16 @@
{
"Connectionstrings":
{
"Diary":"Server=192.168.1.20;Database=Dev_Diary;User Id=sa;Password=P@$$W0rd2021!.;"
},
"Logging": {
"LogLevel": {
"Default": "Trace",
"System": "Information",
"Microsoft": "None"
}
},
"AllowedHosts": "*"
}