diff --git a/dotnet/SafelyYou/Config/HeartBeatHistoryConfig.cs b/dotnet/SafelyYou/Config/HeartBeatHistoryConfig.cs new file mode 100644 index 0000000..88fd548 --- /dev/null +++ b/dotnet/SafelyYou/Config/HeartBeatHistoryConfig.cs @@ -0,0 +1,6 @@ +namespace SafelyYou.Config; + +public class HeartBeatHistoryConfig +{ + public TimeSpan Window { get; set; } +} \ No newline at end of file diff --git a/dotnet/SafelyYou/Devices.cs b/dotnet/SafelyYou/Devices.cs new file mode 100644 index 0000000..ff69c49 --- /dev/null +++ b/dotnet/SafelyYou/Devices.cs @@ -0,0 +1,99 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using SafelyYou.Config; + +namespace SafelyYou; + +public readonly record struct DeviceId(string Value); + + +public sealed class KnownDevices +{ + private readonly HashSet _knownDevices; + + public KnownDevices(HashSet knownDevices) => _knownDevices = knownDevices; + + public bool Contains(DeviceId deviceId) => _knownDevices.Contains(deviceId); +} + +public readonly record struct HeartBeatRange(DateTimeOffset Start, DateTimeOffset? End); + +public sealed class DeviceHeartBeatHistory +{ + private readonly HeartBeatHistoryConfig _config; + + public enum AddResult { Added, OutOfSequence } + + public DeviceHeartBeatHistory(HeartBeatHistoryConfig config) => _config = config; + + private readonly PriorityQueue _histories = + // lowest priority is first, but this allows the highest date to be dequeued first + new PriorityQueue(new InverseDateTimeComparer()); + + private sealed class InverseDateTimeComparer : IComparer + { + public int Compare(DateTimeOffset x, DateTimeOffset y) => -(x.CompareTo(y)); + } + + /// Returns Added, needed to be used as an expression + private static AddResult Replace(PriorityQueue histories, HeartBeatRange updated) + { + histories.DequeueEnqueue(updated, updated.Start); + return AddResult.Added; + } + + /// Returns Added, needed to be used as an expression + private static AddResult EnqueueNew(PriorityQueue histories, DateTimeOffset sentAt) + { + histories.Enqueue(new HeartBeatRange(sentAt, null), sentAt); + return AddResult.Added; + } + + public AddResult Add(DateTimeOffset sentAt) + { + // check the latest first, assuming things are in order + // if it's "in the past" and doesn't make sense, return an error + + // histories that are adjacent are combined, if there's more than a one minute gap (what about a margin of error?) + // then create a new entry. The margin is coming from the config. I've set it to 1 second. + + if (!_histories.TryPeek(out var last, out _)) + return EnqueueNew(_histories, sentAt); + + // last registered heartbeat is End if set, otherwise Start + return (sentAt, last.Start, last.End) switch + { + var (sent, start, _) when sent < start => AddResult.OutOfSequence, + (var sent, _, { } end) when sent < end => AddResult.OutOfSequence, + // no End yet: extend if within Window of Start + (var sent, var start, null) when sent <= start + _config.Window => Replace(_histories, last with { End = sentAt }), + // End set: extend if within Window of End + (var sent, _, { } end) when sent <= end + _config.Window => Replace(_histories, last with { End = sentAt }), + // gap larger than Window: start a new range + _ => EnqueueNew(_histories, sentAt), + }; + } +} + +public sealed class HeartBeatHistories +{ + private readonly HeartBeatHistoryConfig _config; + private readonly ConcurrentDictionary _histories = new(); + + public HeartBeatHistories(HeartBeatHistoryConfig config) => _config = config; + + public DeviceHeartBeatHistory.AddResult Add(DeviceId deviceId, DateTimeOffset sentAt) + { + if (!_histories.TryGetValue(deviceId, out var history)) + { + history = new(_config); + _histories[deviceId] = history; + } + + return history.Add(sentAt); + } +} + + + + diff --git a/dotnet/SafelyYou/Program.cs b/dotnet/SafelyYou/Program.cs index ee9d65d..7baefc0 100644 --- a/dotnet/SafelyYou/Program.cs +++ b/dotnet/SafelyYou/Program.cs @@ -1,9 +1,28 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using SafelyYou; +using SafelyYou.Api; +using SafelyYou.Config; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +builder.Services.AddSingleton(_ => +{ + var deviceIds = File.ReadAllLines("/devices.csv").Skip(1).Select(id => new DeviceId(id)).ToHashSet(); + return new KnownDevices(deviceIds); +}); + +builder.Services.AddSingleton(_ => +{ + var millis = builder.Configuration.GetSection("HeartBeatHistory").GetValue("WindowMillis"); + return new HeartBeatHistoryConfig() { Window = TimeSpan.FromMilliseconds(millis) }; +}); + +builder.Services.AddSingleton(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -14,6 +33,31 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// heartbeat history +// upload stats + + +app.MapPost("/devices/{deviceId}/heartbeat", (string deviceId, HeartbeatRequest request, + KnownDevices knownDevices, HeartBeatHistories histories) => +{ + var device = new DeviceId(deviceId); + if (!knownDevices.Contains(device)) + { + return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found")); + } + + var addResult = histories.Add(device, request.SentAt); + + return addResult switch + { + DeviceHeartBeatHistory.AddResult.Added => Results.Ok(), + DeviceHeartBeatHistory.AddResult.OutOfSequence => Results.InternalServerError( + new ErrorResponse("Attempted to add a timestamp that was out of sequence or invalid.")), + _ => throw new ArgumentOutOfRangeException() + }; +}); + + var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" diff --git a/dotnet/SafelyYou/SafelyYou.csproj b/dotnet/SafelyYou/SafelyYou.csproj index 8f692f0..4338008 100644 --- a/dotnet/SafelyYou/SafelyYou.csproj +++ b/dotnet/SafelyYou/SafelyYou.csproj @@ -10,4 +10,15 @@ + + + + + + + + PreserveNewest + + + diff --git a/dotnet/SafelyYou/appsettings.json b/dotnet/SafelyYou/appsettings.json index 10f68b8..6b45987 100644 --- a/dotnet/SafelyYou/appsettings.json +++ b/dotnet/SafelyYou/appsettings.json @@ -1,4 +1,7 @@ { + "HeartBeatHistory" : { + "WindowMillis" : 1000 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/dotnet/SafelyYou/devices.csv b/dotnet/SafelyYou/devices.csv new file mode 100644 index 0000000..5ed9930 --- /dev/null +++ b/dotnet/SafelyYou/devices.csv @@ -0,0 +1,6 @@ +device_id +60-6b-44-84-dc-64 +b4-45-52-a2-f1-3c +26-9a-66-01-33-83 +18-b8-87-e7-1f-06 +38-4e-73-e0-33-59 diff --git a/dotnet/SafelyYou/openapi.json b/dotnet/SafelyYou/openapi.json new file mode 100644 index 0000000..28882e2 --- /dev/null +++ b/dotnet/SafelyYou/openapi.json @@ -0,0 +1,193 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Fleet Management Simple Metrics Server", + "description": "Simple and correct implementation of the Fleet Management Metrics Coding Assessment", + "contact": { + "name": "API Support", + "url": "http://www.example.com/support", + "email": "support@example.com" + }, + "license": { + "name": "None", + "url": "https://safely-you.com" + }, + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://127.0.0.1:6733/api/v1", + "description": "local server" + } + ], + "paths": { + "/devices/{device_id}/heartbeat": { + "post": { + "description": "Register a heartbeat from a device", + "parameters": [ + { + "$ref": "#/components/parameters/DeviceIDPathParam" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "HeartbeatRequest", + "required": ["sent_at"], + "properties": { + "sent_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "the request was completed successfully" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/devices/{device_id}/stats": { + "post": { + "description": "Add per device statistics", + "parameters": [ + { + "$ref": "#/components/parameters/DeviceIDPathParam" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "UploadStatsRequest", + "required": ["sent_at", "upload_time"], + "properties": { + "sent_at": { + "type": "string", + "format": "date-time" + }, + "upload_time": { + "description": "the number of nanoseconds it took to upload a video", + "type": "integer" + } + } + } + } + } + }, + "responses": { + "204": { + "description": "the request was completed successfully" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/Error" + } + } + }, + "get": { + "description": "Return device stats", + "parameters": [ + { + "$ref": "#/components/parameters/DeviceIDPathParam" + } + ], + "responses": { + "200": { + "description": "Device statistics", + "content": { + "application/json": { + "schema": { + "title": "GetDeviceStatsResponse", + "required": ["avg_upload_time", "uptime"], + "properties": { + "avg_upload_time": { + "description": "returned as a time duration string. Eg: 5m10s", + "type": "string" + }, + "uptime": { + "description": "Uptime as a percentage. eg: 98.999", + "type": "number", + "format": "double" + } + } + } + } + } + }, + "204": { + "description": "the request was completed successfully" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "parameters": { + "DeviceIDPathParam": { + "name": "device_id", + "in": "path", + "description": "ID of a device to register heartbeat with", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "NotFound": { + "description": "Device not found", + "content": { + "application/json": { + "schema": { + "title": "NotFoundResponse", + "type": "object", + "required": ["msg"], + "properties": { + "msg": { + "type": "string" + } + } + } + } + } + }, + "Error": { + "description": "Server Error", + "content": { + "application/json": { + "schema": { + "title": "ErrorResponse", + "type": "object", + "required": ["msg"], + "properties": { + "msg": { + "type": "string" + } + } + } + } + } + } + } + } +}