commit f48b76ce007ee3ac4213cd0de97a28e4a5bc974b Author: Andrii Tykhonov Date: Thu Jul 11 01:20:01 2024 +0300 Implement Devices REST API diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..341621f --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ + +dist + +# Configuration files +src/DevicesRestApi/appsettings.json diff --git a/DevicesRestApi.sln b/DevicesRestApi.sln new file mode 100644 index 0000000..b1d4770 --- /dev/null +++ b/DevicesRestApi.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4A5A8403-EDDE-4155-A3CF-1AF9567A0321}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevicesRestApi", "src\DevicesRestApi\DevicesRestApi.csproj", "{427E1A48-04F1-4C1D-B867-EE717FB2C139}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {427E1A48-04F1-4C1D-B867-EE717FB2C139}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {427E1A48-04F1-4C1D-B867-EE717FB2C139}.Debug|Any CPU.Build.0 = Debug|Any CPU + {427E1A48-04F1-4C1D-B867-EE717FB2C139}.Release|Any CPU.ActiveCfg = Release|Any CPU + {427E1A48-04F1-4C1D-B867-EE717FB2C139}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {427E1A48-04F1-4C1D-B867-EE717FB2C139} = {4A5A8403-EDDE-4155-A3CF-1AF9567A0321} + EndGlobalSection +EndGlobal diff --git a/src/DevicesRestApi/Controllers/ClustersController.cs b/src/DevicesRestApi/Controllers/ClustersController.cs new file mode 100644 index 0000000..d0fedf8 --- /dev/null +++ b/src/DevicesRestApi/Controllers/ClustersController.cs @@ -0,0 +1,114 @@ +namespace DevicesRestApi.Controllers; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using DevicesRestApi.Helpers; + +[ApiController] +[Route("[controller]")] +public class ClustersController : ControllerBase +{ + private ClusterConfigurationFile? _clusterConfigurationFile; + + private readonly ILogger _logger; + + private readonly AppSettings _appSettings; + + public ClustersController( + ILogger logger, + IOptions appSettings + ) { + _logger = logger; + _appSettings = appSettings.Value; + } + + [HttpGet] + [Route("~/clusters/{clusterName}/schedules")] + public IActionResult GetClusterSchedules(string clusterName) + { + _clusterConfigurationFile = null; + try { + _clusterConfigurationFile = new ClusterConfigurationFile(clusterName, _appSettings); + } + catch (ArgumentException e) + { + _logger.LogError($"Something went wrong: {e}"); + return new BadRequest(e.Message); + } + if (!_clusterConfigurationFile.DirectoryExists()) + { + _logger.LogError($"Cluster not found: {clusterName}"); + return new NotFound("Cluster not found"); + } + + var filePath = _clusterConfigurationFile.GetFilePath(); + if (!_clusterConfigurationFile.FileExists()) + { + _logger.LogError($"Configuration file not found: {filePath}"); + return new NotFound("Configuration file not found"); + } + + var schedule = new List(); + + using (StreamReader reader = new StreamReader(filePath)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + var s = line.Split(','); + schedule.Add( + new MeasuringEvent() { + Day = (MeasuringDay)int.Parse(s[0]), + Time = s[1] + } + ); + } + } + + return new Ok( + new{ + name = clusterName, + schedule = schedule, + } + ); + } + + [HttpPost] + [Route("~/clusters/{clusterName}/schedules")] + public IActionResult UpdateClusterSchedules(string clusterName, UpdateScheduleRequest request) + { + var message = "Cluster updated"; + _clusterConfigurationFile = null; + try { + _clusterConfigurationFile = new ClusterConfigurationFile(clusterName, _appSettings); + } + catch (ArgumentException e) + { + _logger.LogError($"Something went wrong: {e}"); + return new BadRequest(e.Message); + } + if (!_clusterConfigurationFile.FileExists()) + { + _clusterConfigurationFile.Create(); + message = "Cluster created"; + } + if (request.Schedule?.Count > 0) + { + var filePath = _clusterConfigurationFile.GetFilePath(); + using (StreamWriter writer = new StreamWriter(filePath)) + { + foreach ( + var e in request.Schedule.OrderBy(s => s.Day).ThenBy( + s => TimeSpan.Parse(s.Time) + ) + ) { + var time = TimeSpan.Parse(e.Time); + writer.WriteLine($"{(int)e.Day},{time.ToString(@"hh\:mm")}"); + writer.Flush(); + } + } + } + + return new Ok(message); + } +} diff --git a/src/DevicesRestApi/DevicesRestApi.csproj b/src/DevicesRestApi/DevicesRestApi.csproj new file mode 100644 index 0000000..46e15ff --- /dev/null +++ b/src/DevicesRestApi/DevicesRestApi.csproj @@ -0,0 +1,19 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/DevicesRestApi/Helpers/AppSettings.cs b/src/DevicesRestApi/Helpers/AppSettings.cs new file mode 100644 index 0000000..3fe24b0 --- /dev/null +++ b/src/DevicesRestApi/Helpers/AppSettings.cs @@ -0,0 +1,8 @@ +namespace DevicesRestApi.Helpers; + +public class AppSettings +{ + public string ApiKey { get; set; } = string.Empty; + + public string ClustersRootPath { get; set; } = string.Empty; +} diff --git a/src/DevicesRestApi/Helpers/ClusterConfigurationFile.cs b/src/DevicesRestApi/Helpers/ClusterConfigurationFile.cs new file mode 100644 index 0000000..7890456 --- /dev/null +++ b/src/DevicesRestApi/Helpers/ClusterConfigurationFile.cs @@ -0,0 +1,75 @@ +namespace DevicesRestApi.Helpers; + +public class ClusterConfigurationFile +{ + private readonly string _rootDirectory; + + private readonly string _clusterName; + + private const string RegularTimeFilename = "regular.time"; + + private readonly AppSettings _appSettings; + + public ClusterConfigurationFile( + string clusterName, + AppSettings appSettings + ) { + _clusterName = clusterName; + _appSettings = appSettings; + _rootDirectory = _appSettings.ClustersRootPath; + + if (string.IsNullOrEmpty(_clusterName)) + { + throw new ArgumentException("The provided cluster name is null or empty."); + } + if (string.IsNullOrEmpty(_rootDirectory)) + { + throw new ArgumentException("Clusters root directory is not configured."); + } + } + + private string GetDirectoryPath() + { + return Path.Combine(_rootDirectory, _clusterName); + } + + public string GetFilePath() + { + return Path.Combine(GetDirectoryPath(), RegularTimeFilename); + } + + public bool FileExists() + { + return File.Exists(GetFilePath()); + } + + public bool DirectoryExists() + { + return Directory.Exists(GetDirectoryPath()); + } + + private void CreateDirectory() + { + Directory.CreateDirectory(GetDirectoryPath()); + } + + private void CreateFile() + { + using (FileStream fs = File.Create(GetFilePath())) + { + // The file is created and opened, we don't need to write anything to it + } + } + + public void Create() + { + if (!DirectoryExists()) + { + CreateDirectory(); + } + if (!FileExists()) + { + CreateFile(); + } + } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Helpers/MeasuringDay.cs b/src/DevicesRestApi/Helpers/MeasuringDay.cs new file mode 100644 index 0000000..c253445 --- /dev/null +++ b/src/DevicesRestApi/Helpers/MeasuringDay.cs @@ -0,0 +1,12 @@ +namespace DevicesRestApi.Helpers; + +public enum MeasuringDay +{ + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, + SUNDAY = 7 +} \ No newline at end of file diff --git a/src/DevicesRestApi/Helpers/MeasuringEvent.cs b/src/DevicesRestApi/Helpers/MeasuringEvent.cs new file mode 100644 index 0000000..9c3b931 --- /dev/null +++ b/src/DevicesRestApi/Helpers/MeasuringEvent.cs @@ -0,0 +1,7 @@ +namespace DevicesRestApi.Helpers; + +public class MeasuringEvent +{ + public MeasuringDay Day { get; set; } + public string Time { get; set; } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Helpers/UpdateScheduleRequest.cs b/src/DevicesRestApi/Helpers/UpdateScheduleRequest.cs new file mode 100644 index 0000000..2deb646 --- /dev/null +++ b/src/DevicesRestApi/Helpers/UpdateScheduleRequest.cs @@ -0,0 +1,6 @@ +namespace DevicesRestApi.Helpers; + +public class UpdateScheduleRequest +{ + public List? Schedule { get; set; } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Logs/.gitignore b/src/DevicesRestApi/Logs/.gitignore new file mode 100644 index 0000000..7dc54a5 --- /dev/null +++ b/src/DevicesRestApi/Logs/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything +* + +# But not this file +!.gitignore diff --git a/src/DevicesRestApi/Middlewares/ApiKeyMiddleware.cs b/src/DevicesRestApi/Middlewares/ApiKeyMiddleware.cs new file mode 100644 index 0000000..da8cfe0 --- /dev/null +++ b/src/DevicesRestApi/Middlewares/ApiKeyMiddleware.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using System.Threading.Tasks; + +public class ApiKeyMiddleware +{ + private readonly RequestDelegate _next; + private const string ApiKeyHeaderName = "X-Api-Key"; + + public ApiKeyMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IConfiguration configuration) + { + if (!context.Request.Headers.TryGetValue(ApiKeyHeaderName, out var extractedApiKey)) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("API key was not provided."); + return; + } + + var appSettings = configuration.GetSection("AppSettings"); + var apiKey = appSettings.GetValue("ApiKey"); + + if (!extractedApiKey.Equals(apiKey)) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized client."); + return; + } + + await _next(context); + } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Middlewares/ErrorHandlerMiddleware.cs b/src/DevicesRestApi/Middlewares/ErrorHandlerMiddleware.cs new file mode 100644 index 0000000..d24c7b4 --- /dev/null +++ b/src/DevicesRestApi/Middlewares/ErrorHandlerMiddleware.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System; +using System.Net; +using System.Threading.Tasks; + +public class ErrorHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ErrorHandlerMiddleware( + RequestDelegate next, + ILogger logger + ) { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await _next(httpContext); + } + catch (Exception ex) + { + _logger.LogError($"Something went wrong: {ex}"); + await HandleExceptionAsync(httpContext, ex); + } + } + + private Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + + var response = new + { + status = context.Response.StatusCode, + message = "Internal Server Error.", + detailed = exception.Message + }; + + return context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(response)); + } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Middlewares/NotFoundMiddleware.cs b/src/DevicesRestApi/Middlewares/NotFoundMiddleware.cs new file mode 100644 index 0000000..98414a0 --- /dev/null +++ b/src/DevicesRestApi/Middlewares/NotFoundMiddleware.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using System.Threading.Tasks; + +public class NotFoundMiddleware +{ + private readonly RequestDelegate _next; + + public NotFoundMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + await _next(context); + + if (context.Response.StatusCode == 404 && !context.Response.HasStarted) + { + context.Response.ContentType = "application/json"; + var errorResponse = new + { + status = 404, + message = "Not Found" + }; + await context.Response.WriteAsync(JsonConvert.SerializeObject(errorResponse)); + } + } +} diff --git a/src/DevicesRestApi/Program.cs b/src/DevicesRestApi/Program.cs new file mode 100644 index 0000000..7ced23d --- /dev/null +++ b/src/DevicesRestApi/Program.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Serilog; + +using DevicesRestApi.Helpers; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.Configure(builder.Configuration.GetSection("AppSettings")); + +var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .AddJsonFile("serilog.json", optional: true, reloadOnChange: true) + .Build(); + +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + +// Suppress the default logging providers to avoid duplicate messages +/* +builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); +*/ + +builder.Host.UseSerilog(); + +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(Log.Logger); + +try { + Log.Information("Starting up"); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.MapControllers(); + + app.Run(); +} +catch (Exception e) +{ + Log.Fatal(e, "Application start-up failed"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/src/DevicesRestApi/Properties/launchSettings.json b/src/DevicesRestApi/Properties/launchSettings.json new file mode 100644 index 0000000..5ce93f5 --- /dev/null +++ b/src/DevicesRestApi/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53426", + "sslPort": 44360 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5148", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7113;http://localhost:5148", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/DevicesRestApi/Results/BadRequest.cs b/src/DevicesRestApi/Results/BadRequest.cs new file mode 100644 index 0000000..27aafc8 --- /dev/null +++ b/src/DevicesRestApi/Results/BadRequest.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +public class BadRequest : ObjectResult +{ + public BadRequest(string message) + : base(new { status = 400, message = message }) + { + StatusCode = StatusCodes.Status400BadRequest; + } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Results/NotFound.cs b/src/DevicesRestApi/Results/NotFound.cs new file mode 100644 index 0000000..678adcb --- /dev/null +++ b/src/DevicesRestApi/Results/NotFound.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +public class NotFound : ObjectResult +{ + public NotFound(string message) + : base(new { status = 404, message = message }) + { + StatusCode = StatusCodes.Status404NotFound; + } +} \ No newline at end of file diff --git a/src/DevicesRestApi/Results/Ok.cs b/src/DevicesRestApi/Results/Ok.cs new file mode 100644 index 0000000..ae386a4 --- /dev/null +++ b/src/DevicesRestApi/Results/Ok.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; + +public class Ok : ObjectResult +{ + public Ok(string message = null) + : base(new { status = 200, message = message }) + { + StatusCode = StatusCodes.Status200OK; + } + + public Ok(object data = null) + : base(new { status = 200, data = data }) + { + StatusCode = StatusCodes.Status200OK; + } +} \ No newline at end of file diff --git a/src/DevicesRestApi/appsettings.example.json b/src/DevicesRestApi/appsettings.example.json new file mode 100644 index 0000000..b86bf1c --- /dev/null +++ b/src/DevicesRestApi/appsettings.example.json @@ -0,0 +1,40 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System": "Warning" + } + }, + "AppSettings": { + "ApiKey": "api-key", + "ClustersRootPath": "/tmp/projects" + }, + "AllowedHosts": "*", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "File", + "Args": { + "path": "Logs/log-.txt", + "rollingInterval": "Day" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ], + "Properties": { + "Application": "DevicesRestApi" + } + } +}