Implement Devices REST API

This commit is contained in:
Andrii Tykhonov 2024-07-11 01:20:01 +03:00
commit f48b76ce00
19 changed files with 618 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -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

27
DevicesRestApi.sln Normal file
View File

@ -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

View File

@ -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<ClustersController> _logger;
private readonly AppSettings _appSettings;
public ClustersController(
ILogger<ClustersController> logger,
IOptions<AppSettings> 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<MeasuringEvent>();
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);
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.20" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace DevicesRestApi.Helpers;
public class AppSettings
{
public string ApiKey { get; set; } = string.Empty;
public string ClustersRootPath { get; set; } = string.Empty;
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,12 @@
namespace DevicesRestApi.Helpers;
public enum MeasuringDay
{
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6,
SUNDAY = 7
}

View File

@ -0,0 +1,7 @@
namespace DevicesRestApi.Helpers;
public class MeasuringEvent
{
public MeasuringDay Day { get; set; }
public string Time { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace DevicesRestApi.Helpers;
public class UpdateScheduleRequest
{
public List<MeasuringEvent>? Schedule { get; set; }
}

5
src/DevicesRestApi/Logs/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Ignore everything
*
# But not this file
!.gitignore

View File

@ -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<string>("ApiKey");
if (!extractedApiKey.Equals(apiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Unauthorized client.");
return;
}
await _next(context);
}
}

View File

@ -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<ErrorHandlerMiddleware> _logger;
public ErrorHandlerMiddleware(
RequestDelegate next,
ILogger<ErrorHandlerMiddleware> 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));
}
}

View File

@ -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));
}
}
}

View File

@ -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<AppSettings>(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<ApiKeyMiddleware>();
app.UseMiddleware<ErrorHandlerMiddleware>();
app.UseMiddleware<NotFoundMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
catch (Exception e)
{
Log.Fatal(e, "Application start-up failed");
}
finally
{
Log.CloseAndFlush();
}

View File

@ -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"
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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"
}
}
}