Skip to content

Add admin configuration service #209

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Common/Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.6.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NRedisStack" Version="1.0.0" />
Expand Down
20 changes: 20 additions & 0 deletions Common/OpenShockDb/ConfigurationItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace OpenShock.Common.OpenShockDb;

public enum ConfigurationValueType
{
String,
Bool,
Int,
Float,
Json
}

public class ConfigurationItem
{
public required string Name { get; set; }
public required string Description { get; set; }
public required ConfigurationValueType Type { get; set; }
public required string Value { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime CreatedAt { get; set; }
}
23 changes: 23 additions & 0 deletions Common/OpenShockDb/OpenShockContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde

public DbSet<DiscordWebhook> DiscordWebhooks { get; set; }

public DbSet<ConfigurationItem> Configuration { get; set; }

public DbSet<AdminUsersView> AdminUsersViews { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
Expand Down Expand Up @@ -722,6 +724,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasColumnName("created_at");
});

modelBuilder.Entity<ConfigurationItem>(entity =>
{
entity.HasKey(e => e.Name).HasName("configuration_pkey");

entity.ToTable("configuration");

entity.Property(e => e.Name)
.HasColumnName("name");
entity.Property(e => e.Description)
.HasColumnName("description");
entity.Property(e => e.Type)
.HasColumnName("type");
entity.Property(e => e.Value)
.HasColumnName("value");
entity.Property(e => e.UpdatedAt)
.HasColumnName("updated_at");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("CURRENT_TIMESTAMP")
.HasColumnName("created_at");
});

modelBuilder.Entity<AdminUsersView>(entity =>
{
entity
Expand Down
16 changes: 15 additions & 1 deletion Common/OpenShockServiceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection.Extensions;
using OpenShock.Common.Authentication;
using OpenShock.Common.Authentication.AuthenticationHandlers;
Expand All @@ -13,6 +14,7 @@
using OpenShock.Common.Options;
using OpenShock.Common.Problems;
using OpenShock.Common.Services.BatchUpdate;
using OpenShock.Common.Services.Configuration;
using OpenShock.Common.Services.RedisPubSub;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Services.Webhook;
Expand Down Expand Up @@ -105,7 +107,18 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se
{
// <---- ASP.NET ---->
services.AddExceptionHandler<OpenShockExceptionHandler>();


services.AddHybridCache(options =>
{
options.MaximumPayloadBytes = 1024 * 1024;
options.MaximumKeyLength = 1024;
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(5)
};
});

services.AddScoped<IClientAuthService<User>, ClientAuthService<User>>();
services.AddScoped<IClientAuthService<Device>, ClientAuthService<Device>>();
services.AddScoped<IUserReferenceService, UserReferenceService>();
Expand Down Expand Up @@ -197,6 +210,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se

// <---- OpenShock Services ---->

services.AddScoped<IConfigurationService, ConfigurationService>();
services.AddScoped<ISessionService, SessionService>();
services.AddHttpClient<IWebhookService, WebhookService>(client =>
{
Expand Down
189 changes: 189 additions & 0 deletions Common/Services/Configuration/ConfigurationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Caching.Memory;
using OneOf;
using OneOf.Types;
using OpenShock.Common.OpenShockDb;
using System.Text.Json;

namespace OpenShock.Common.Services.Configuration;

public sealed class ConfigurationService : IConfigurationService
{
private readonly HybridCache _cache;
private readonly OpenShockContext _db;
private readonly ILogger<ConfigurationService> _logger;

public ConfigurationService(HybridCache cache, OpenShockContext db, ILogger<ConfigurationService> logger)
{
_cache = cache;
_db = db;
_logger = logger;
}

private sealed record TypeValuePair(ConfigurationValueType Type, string Value);
private async Task<TypeValuePair?> TryGetTypeValuePair(string name)
{
return await _cache.GetOrCreateAsync(name, async cancellationToken =>
{
var pair = await _db.Configuration
.Where(ci => ci.Name == name)
.Select(ci => new TypeValuePair(ci.Type, ci.Value))
.FirstOrDefaultAsync(cancellationToken);

return pair;
});
}

private async Task<bool> SetValueAsync(string name, string newValue, ConfigurationValueType type)
{
var item = await _db.Configuration.FirstOrDefaultAsync(c => c.Name == name);

if (item is null)
{
var now = DateTime.UtcNow;

_db.Configuration.Add(new ConfigurationItem
{
Name = name,
Description = "Auto-added by ConfigurationService",
Type = type,
Value = newValue,
UpdatedAt = now,
CreatedAt = now
});
}
else
{
if (item.Type != type)
{
_logger.LogWarning("Type mismatch for config '{Name}': expected {Expected}, got {Actual}", name, item.Type, type);
return false;
}

item.Value = newValue;
item.UpdatedAt = DateTime.UtcNow;
}

await _db.SaveChangesAsync();
await _cache.RemoveAsync(name); // Invalidate hybrid cache
return true;
}

public async Task<ConfigurationItem[]> GetAllItemsAsync(string name)
{
return await _db.Configuration.ToArrayAsync();
}

public async Task<OneOf<ConfigurationItem, NotFound>> GetItemAsync(string name)
{
var item = await _db.Configuration.FirstOrDefaultAsync(ci => ci.Name == name);
return item is null ? new NotFound() : item;
}

public async Task<bool> CheckItemExistsAsync(string name)
{
return await _db.Configuration.AnyAsync(ci => ci.Name == name);
}

public async Task<OneOf<ConfigurationValueType, NotFound>> TryGetItemTypeAsync(string name)
{
var type = await _db.Configuration
.Where(c => c.Name == name)
.Select(c => (ConfigurationValueType?)c.Type)
.FirstOrDefaultAsync();

return type.HasValue ? type.Value : new NotFound();
}

public async Task<OneOf<string, NotFound, InvalidType>> TryGetStringAsync(string name)
{
var pair = await TryGetTypeValuePair(name);
if (pair is null) return new NotFound();
if (pair.Type != ConfigurationValueType.String) return new InvalidType();
return pair.Value;
}

public Task<bool> TrySetStringAsync(string name, string value) =>
SetValueAsync(name, value, ConfigurationValueType.String);

public async Task<OneOf<bool, NotFound, InvalidType, InvalidConfiguration>> TryGetBoolAsync(string name)
{
var pair = await TryGetTypeValuePair(name);
if (pair is null) return new NotFound();
if (pair.Type != ConfigurationValueType.Bool) return new InvalidType();
if (!bool.TryParse(pair.Value, out var value))
{
_logger.LogWarning("Failed to parse bool for '{Name}': Value='{Value}'", name, pair.Value);
return new InvalidConfiguration();
}
return value;
}

public Task<bool> TrySetBoolAsync(string name, bool value) =>
SetValueAsync(name, value.ToString(), ConfigurationValueType.Bool);

public async Task<OneOf<int, NotFound, InvalidType, InvalidConfiguration>> TryGetIntAsync(string name)
{
var pair = await TryGetTypeValuePair(name);
if (pair is null) return new NotFound();
if (pair.Type != ConfigurationValueType.Int) return new InvalidType();
if (!sbyte.TryParse(pair.Value, out var value))
{
_logger.LogWarning("Failed to parse sbyte for '{Name}': Value='{Value}'", name, pair.Value);
return new InvalidConfiguration();
}
return value;
}

public Task<bool> TrySetIntAsync(string name, int value) =>
SetValueAsync(name, value.ToString(), ConfigurationValueType.Int);

public async Task<OneOf<float, NotFound, InvalidType, InvalidConfiguration>> TryGetFloatAsync(string name)
{
var pair = await TryGetTypeValuePair(name);
if (pair is null) return new NotFound();
if (pair.Type != ConfigurationValueType.Float) return new InvalidType();
if (!float.TryParse(pair.Value, out var value))
{
_logger.LogWarning("Failed to parse float for '{Name}': Value='{Value}'", name, pair.Value);
return new InvalidConfiguration();
}
return value;
}

public Task<bool> TrySetFloatAsync(string name, float value) =>
SetValueAsync(name, value.ToString("R"), ConfigurationValueType.Float);

public async Task<OneOf<T, NotFound, InvalidType, InvalidConfiguration>> TryGetJsonAsync<T>(string name)
{
var pair = await TryGetTypeValuePair(name);
if (pair is null) return new NotFound();
if (pair.Type != ConfigurationValueType.Json) return new InvalidType();

try
{
var obj = JsonSerializer.Deserialize<T>(pair.Value);
return obj is not null ? obj : new InvalidConfiguration();
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize JSON for '{Name}'", name);
return new InvalidConfiguration();
}
}

public async Task<bool> TrySetJsonAsync<T>(string name, T value)
{
try
{
var json = JsonSerializer.Serialize(value);
return await SetValueAsync(name, json, ConfigurationValueType.Json);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to serialize value for '{Name}'", name);
return false;
}
}
}
32 changes: 32 additions & 0 deletions Common/Services/Configuration/IConfigurationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using OneOf;
using OneOf.Types;
using OpenShock.Common.OpenShockDb;

namespace OpenShock.Common.Services.Configuration;

public interface IConfigurationService
{
Task<ConfigurationItem[]> GetAllItemsAsync(string name);

Task<OneOf<ConfigurationItem, NotFound>> GetItemAsync(string name);
Task<bool> CheckItemExistsAsync(string name);
Task<OneOf<ConfigurationValueType, NotFound>> TryGetItemTypeAsync(string name);

Task<OneOf<string, NotFound, InvalidType>> TryGetStringAsync(string name);
Task<bool> TrySetStringAsync(string name, string value);

Task<OneOf<bool, NotFound, InvalidType, InvalidConfiguration>> TryGetBoolAsync(string name);
Task<bool> TrySetBoolAsync(string name, bool value);

Task<OneOf<int, NotFound, InvalidType, InvalidConfiguration>> TryGetIntAsync(string name);
Task<bool> TrySetIntAsync(string name, int value);

Task<OneOf<float, NotFound, InvalidType, InvalidConfiguration>> TryGetFloatAsync(string name);
Task<bool> TrySetFloatAsync(string name, float value);

Task<OneOf<T, NotFound, InvalidType, InvalidConfiguration>> TryGetJsonAsync<T>(string name);
Task<bool> TrySetJsonAsync<T>(string name, T value);
}

public readonly struct InvalidType;
public readonly struct InvalidConfiguration;
Loading