add posting of a heart beat
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SafelyYou.Config;
|
||||||
|
|
||||||
|
public class HeartBeatHistoryConfig
|
||||||
|
{
|
||||||
|
public TimeSpan Window { get; set; }
|
||||||
|
}
|
||||||
@@ -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<DeviceId> _knownDevices;
|
||||||
|
|
||||||
|
public KnownDevices(HashSet<DeviceId> 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<HeartBeatRange, DateTimeOffset> _histories =
|
||||||
|
// lowest priority is first, but this allows the highest date to be dequeued first
|
||||||
|
new PriorityQueue<HeartBeatRange, DateTimeOffset>(new InverseDateTimeComparer());
|
||||||
|
|
||||||
|
private sealed class InverseDateTimeComparer : IComparer<DateTimeOffset>
|
||||||
|
{
|
||||||
|
public int Compare(DateTimeOffset x, DateTimeOffset y) => -(x.CompareTo(y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns Added, needed to be used as an expression</summary>
|
||||||
|
private static AddResult Replace(PriorityQueue<HeartBeatRange, DateTimeOffset> histories, HeartBeatRange updated)
|
||||||
|
{
|
||||||
|
histories.DequeueEnqueue(updated, updated.Start);
|
||||||
|
return AddResult.Added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns Added, needed to be used as an expression</summary>
|
||||||
|
private static AddResult EnqueueNew(PriorityQueue<HeartBeatRange, DateTimeOffset> 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<DeviceId, DeviceHeartBeatHistory> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using SafelyYou;
|
||||||
|
using SafelyYou.Api;
|
||||||
|
using SafelyYou.Config;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<KnownDevices>(_ =>
|
||||||
|
{
|
||||||
|
var deviceIds = File.ReadAllLines("/devices.csv").Skip(1).Select(id => new DeviceId(id)).ToHashSet();
|
||||||
|
return new KnownDevices(deviceIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<HeartBeatHistoryConfig>(_ =>
|
||||||
|
{
|
||||||
|
var millis = builder.Configuration.GetSection("HeartBeatHistory").GetValue<int>("WindowMillis");
|
||||||
|
return new HeartBeatHistoryConfig() { Window = TimeSpan.FromMilliseconds(millis) };
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<HeartBeatHistories>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
@@ -14,6 +33,31 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
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[]
|
var summaries = new[]
|
||||||
{
|
{
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||||
|
|||||||
@@ -10,4 +10,15 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Api\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="devices.csv" />
|
||||||
|
<Content Include="devices.csv">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"HeartBeatHistory" : {
|
||||||
|
"WindowMillis" : 1000
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -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
|
||||||
|
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user