Skip to content
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
33 changes: 25 additions & 8 deletions src/Cove.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Serilog.Events;
using Cove.Api.Hubs;
using Cove.Api.Services;
using Cove.Core.Common;
using Cove.Core.Entities.Galleries;
using Cove.Core.Events;
using Cove.Core.Interfaces;
Expand Down Expand Up @@ -59,6 +60,23 @@ static bool IsWeakJwtSecret(string? secret)
static string GenerateJwtSecret()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));

// Copies the canonical wire configuration from CoveJson.Default onto a framework-provided options
// object. Framework hooks (MVC, SignalR, Http.Json) each expose their OWN JsonSerializerOptions that
// must be configured in place — the frozen CoveJson.Default cannot be assigned by reference — so the
// naming policy, combined type-info resolver, and EVERY converter are copied across. Copying the
// converter list is what carries the enum behavior: the global JsonStringEnumConverter(CamelCase) is
// the string-enum mechanism on the reflection-fallback path (it does not travel via the source-gen
// context's UseStringEnumConverter policy), and the type-specific CriterionModifierJsonConverter is
// preserved ahead of it (first-match-wins ordering is inherited from CoveJson.Default).
static void ApplyCanonicalJson(System.Text.Json.JsonSerializerOptions options)
{
options.PropertyNamingPolicy = CoveJson.Default.PropertyNamingPolicy;
options.PropertyNameCaseInsensitive = CoveJson.Default.PropertyNameCaseInsensitive;
options.TypeInfoResolver = CoveJson.Default.TypeInfoResolver;
foreach (var converter in CoveJson.Default.Converters)
options.Converters.Add(converter);
}

static LogEventLevel ParseSerilogLogLevel(string? level)
=> level?.Trim().ToLowerInvariant() switch
{
Expand Down Expand Up @@ -391,10 +409,7 @@ LIMIT 1

// SignalR
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
.AddJsonProtocol(options => ApplyCanonicalJson(options.PayloadSerializerOptions));

// Auth
var authConfig = coveConfig.GetSection("Auth");
Expand Down Expand Up @@ -438,10 +453,12 @@ LIMIT 1
options.Filters.Add<Cove.Api.Middleware.PermissionAuthorizationFilter>();
options.Filters.Add<Cove.Api.Middleware.EntityAccessActionFilter>();
})
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
.AddJsonOptions(options => ApplyCanonicalJson(options.JsonSerializerOptions));

// Minimal-API + extension endpoint serialization. Extension MapEndpoints responses (and any
// extension code writing via HttpContext.Response.WriteAsJsonAsync) inherit these options; before
// this registration the path fell back to bare web defaults and emitted enums as integers.
builder.Services.ConfigureHttpJsonOptions(options => ApplyCanonicalJson(options.SerializerOptions));
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
Expand Down
7 changes: 4 additions & 3 deletions src/Cove.Api/Services/ConfigService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Cove.Core.Common;
using Cove.Core.DTOs;
using Cove.Core.Interfaces;

Expand All @@ -15,12 +16,12 @@ public class ConfigService
private readonly CoveConfiguration _config;
private readonly ILogger<ConfigService> _logger;
private readonly string _configPath;
private readonly JsonSerializerOptions _jsonOpts = new()
// Config file already stores camelCase properties and enum strings, so the canonical options
// produce byte-compatible output; WriteIndented preserves the on-disk file shape.
private readonly JsonSerializerOptions _jsonOpts = new(CoveJson.Default)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
private readonly SemaphoreSlim _lock = new(1, 1);

Expand Down
50 changes: 3 additions & 47 deletions src/Cove.Api/Services/DynamicGroups.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Nodes;
using System.Linq.Expressions;
using Cove.Core.Auth;
using Cove.Core.Common;
using Cove.Core.DTOs;
using Cove.Core.Entities;
using Cove.Core.Enums;
Expand Down Expand Up @@ -522,11 +522,6 @@ protected async Task<DynamicGroupResolveResult> HydratePageAsync(

public sealed class FilterDynamicGroupSource(CoveContext db, IVideoRepository videoRepository, IImageRepository imageRepository) : IDynamicGroupSource, IDynamicGroupCountingSource
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new CriterionModifierJsonConverter(), new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};

public string Key => DynamicGroupResolver.FilterSourceKey;
public string DisplayName => "Filtered Entities";

Expand Down Expand Up @@ -923,53 +918,14 @@ private static bool IsSupportedEntityType(string entityType)

try
{
return objectFilter.Value.Deserialize<TFilter>(JsonOptions);
return objectFilter.Value.Deserialize<TFilter>(CoveJson.Default);
}
catch (JsonException)
{
return default;
}
}

private sealed class CriterionModifierJsonConverter : JsonConverter<CriterionModifier>
{
public override CriterionModifier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String && TryParse(reader.GetString(), out var modifier))
return modifier;

if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numeric) && Enum.IsDefined(typeof(CriterionModifier), numeric))
return (CriterionModifier)numeric;

throw new JsonException($"Invalid criterion modifier token '{reader.TokenType}'.");
}

public override void Write(Utf8JsonWriter writer, CriterionModifier value, JsonSerializerOptions options)
=> writer.WriteStringValue(JsonNamingPolicy.CamelCase.ConvertName(value.ToString()));

private static bool TryParse(string? value, out CriterionModifier modifier)
{
modifier = default;
if (string.IsNullOrWhiteSpace(value))
return false;

var normalized = Normalize(value);
foreach (var name in Enum.GetNames<CriterionModifier>())
{
if (!string.Equals(Normalize(name), normalized, StringComparison.OrdinalIgnoreCase))
continue;

modifier = Enum.Parse<CriterionModifier>(name);
return true;
}

return false;
}

private static string Normalize(string value)
=> new(value.Where(char.IsLetterOrDigit).ToArray());
}

private static string NormalizeEntityType(string? entityType)
{
var normalized = string.IsNullOrWhiteSpace(entityType) ? "video" : entityType.Trim().ToLowerInvariant();
Expand Down Expand Up @@ -1647,7 +1603,7 @@ private static FilterDynamicGroupQuery ParseQuery(string? queryJson)

try
{
return JsonSerializer.Deserialize<FilterDynamicGroupQuery>(queryJson, JsonOptions) ?? new FilterDynamicGroupQuery();
return JsonSerializer.Deserialize<FilterDynamicGroupQuery>(queryJson, CoveJson.Default) ?? new FilterDynamicGroupQuery();
}
catch (JsonException)
{
Expand Down
5 changes: 4 additions & 1 deletion src/Cove.Api/Services/FieldProvenanceService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using Cove.Core.Common;
using Cove.Core.DTOs;
using Cove.Core.Entities;
using Cove.Core.Interfaces;
Expand All @@ -9,7 +10,9 @@ namespace Cove.Api.Services;

public sealed class FieldProvenanceService(CoveContext db) : IFieldProvenanceService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
// Dynamic audit blob (no fixed host enums); the canonical resolver's reflection fallback
// handles the runtime type passed to Serialize.
private static readonly JsonSerializerOptions JsonOptions = CoveJson.Default;

public async Task RecordAsync(
AffinityHostType hostType,
Expand Down
4 changes: 3 additions & 1 deletion src/Cove.Api/Services/ScrapeAttemptService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text;
using Cove.Core.Common;
using Cove.Core.DTOs;
using Cove.Core.Entities;
using Cove.Core.Interfaces;
Expand All @@ -11,7 +12,8 @@ namespace Cove.Api.Services;

public class ScrapeAttemptService(CoveContext db, ScraperService scraperService, IVideoCoverService videoCoverService, PerformerScrapeService performerScrapeService, ITagProvenanceService tagProvenanceService, IGroupMetadataApplyService groupMetadataApplyService, ILogger<ScrapeAttemptService> logger, IFieldProvenanceService? fieldProvenanceService = null)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
// Persisted scrape blobs are dynamic Dictionary<string, object?> payloads with no host enums.
private static readonly JsonSerializerOptions JsonOptions = CoveJson.Default;

private static string BuildScraperSourceKey(string? scraperId)
=> string.IsNullOrWhiteSpace(scraperId) ? "scraper" : $"scraper:{scraperId.Trim()}";
Expand Down
3 changes: 2 additions & 1 deletion src/Cove.Api/Services/ScraperService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using HtmlAgilityPack;
using Cove.Core.Common;
using Cove.Core.DTOs;
using Cove.Core.Interfaces;
using Cove.Plugins;
Expand Down Expand Up @@ -36,7 +37,7 @@ public class ScraperService
private readonly Dictionary<string, ExtensionScraperRegistration> _extensionScraperCache = new(StringComparer.OrdinalIgnoreCase);
private const string BuiltinScraperSourcePath = "builtin:cove.core.scrapers";
private static readonly Regex BracketTagRegex = new(@"\[[^\[\]\r\n]{1,80}\]", RegexOptions.Compiled);
private static readonly JsonSerializerOptions ExtensionScrapeJsonOptions = new(JsonSerializerDefaults.Web)
private static readonly JsonSerializerOptions ExtensionScrapeJsonOptions = new(CoveJson.Default)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
Expand Down
51 changes: 49 additions & 2 deletions src/Cove.Core/Common/CoveJson.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,55 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace Cove.Core.Common;

/// <summary>
/// The single canonical <see cref="JsonSerializerOptions"/> for Cove's wire format. Every
/// serialization boundary (MVC, SignalR, minimal-API/extension endpoints, and manual
/// serialize/deserialize call sites) routes through this instance so the emitted shape is
/// consistent: camelCase properties, case-insensitive lenient reads, and camelCase named-string
/// enums (int or string accepted on read).
/// </summary>
public static class CoveJson
{
public static JsonSerializerOptions Default { get; } = new(JsonSerializerDefaults.Web);
}
/// <summary>
/// Frozen canonical options. Combines the source-generated <see cref="CoveJsonContext"/>
/// (fast path for registered host DTOs) with a reflection resolver so runtime-loaded
/// extension types still serialize. Enums emit as camelCase strings via the global
/// converter — including reflection-fallback enums that the source-gen context does not
/// cover — while <c>CriterionModifier</c> keeps its type-specific lenient converter.
/// </summary>
public static JsonSerializerOptions Default { get; } = BuildDefault();

private static JsonSerializerOptions BuildDefault()
{
// Retain Web semantics: camelCase properties, case-insensitive read, AllowReadingFromString.
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
// Source-gen for registered host types; reflection fallback for everything else,
// including per-extension AssemblyLoadContext types that cannot be pre-registered.
TypeInfoResolver = JsonTypeInfoResolver.Combine(
CoveJsonContext.Default,
new DefaultJsonTypeInfoResolver()),
};

// Order matters. System.Text.Json evaluates the Converters collection in registration
// order and uses the FIRST converter whose CanConvert(type) is true (it does not rank by
// specificity). So the type-specific CriterionModifier converter MUST be registered before
// the global enum factory to win for CriterionModifier — otherwise the factory (which
// matches every enum) would intercept it and drop its extra read leniency
// (greater_than / GREATER_THAN separator-insensitive forms).
// (1) Type-specific converter: keeps CriterionModifier's lenient read; concrete
// JsonConverter<CriterionModifier> only matches that one type.
// (2) Global string-enum policy: covers the reflection-fallback path (all extension-ALC
// enums and every unregistered host DTO enum); without it those enums would emit their
// default integer and defeat the canonical wire format.
options.Converters.Add(new CriterionModifierJsonConverter());
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));

// Freeze once, after the resolver and both converters are set.
options.MakeReadOnly();
return options;
}
}
41 changes: 41 additions & 0 deletions src/Cove.Core/Common/CoveJsonContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
using Cove.Core.Auth;
using Cove.Core.DTOs;

namespace Cove.Core.Common;

/// <summary>
/// Source-generated serializer context backing the canonical <see cref="CoveJson.Default"/>
/// options. Provides fast, generator-produced metadata for the registered host DTO roots;
/// anything not registered here degrades to the reflection resolver combined into
/// <see cref="CoveJson.Default"/> (it does not throw), so this list can grow incrementally.
///
/// <para>
/// <c>UseStringEnumConverter = true</c> is the fast path for enums reachable from the
/// registered <see cref="JsonSerializableAttribute"/> graph only. Enums resolved via the
/// reflection fallback — every runtime-loaded extension type and every unregistered host
/// DTO enum — are governed by the global string-enum converter added to
/// <see cref="CoveJson.Default"/>, not by this attribute.
/// </para>
///
/// <para>
/// Polymorphic wire types convention (for future use — no wire-polymorphic type exists today):
/// annotate a base type that is genuinely serialized as its base over the wire with
/// <c>[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]</c> and string-valued
/// <c>[JsonDerivedType(typeof(TDerived), "kind")]</c> discriminators. Rules to respect:
/// the discriminator must appear first on read; it must NOT be declared <c>required</c>;
/// values must always be serialized through the base type (a manual
/// <c>Serialize&lt;TConcrete&gt;</c> drops the discriminator); and consider
/// <c>JsonUnknownDerivedTypeHandling</c> so newer derived types keep a discriminator when
/// round-tripped through an older reader. Do not annotate persistence-only or in-process
/// hierarchies that never cross the wire as their base type.
/// </para>
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true)]
[JsonSerializable(typeof(VideoDto))]
[JsonSerializable(typeof(PaginatedResponse<VideoDto>))]
[JsonSerializable(typeof(CoveConfigDto))]
[JsonSerializable(typeof(UserUiPreferencesDto))]
public partial class CoveJsonContext : JsonSerializerContext
{
}
55 changes: 55 additions & 0 deletions src/Cove.Core/Common/CriterionModifierJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Cove.Core.Interfaces;

namespace Cove.Core.Common;

/// <summary>
/// Type-specific converter for <see cref="CriterionModifier"/>. Writes the camelCase named
/// string (identical to the shared string-enum policy), but reads more leniently: it accepts
/// separator-insensitive, case-insensitive forms (e.g. <c>greater_than</c>, <c>GREATER_THAN</c>,
/// <c>greater than</c>) in addition to the canonical camelCase string and the underlying integer.
///
/// Registered on the canonical options ahead of the global string-enum converter; a type-specific
/// <see cref="JsonConverter{T}"/> takes precedence, so <see cref="CriterionModifier"/> keeps this
/// extra read leniency while every other enum flows through the global camelCase policy. The wire
/// names are public API — do not rename the members without keeping the emitted strings stable.
/// </summary>
public sealed class CriterionModifierJsonConverter : JsonConverter<CriterionModifier>
{
public override CriterionModifier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String && TryParse(reader.GetString(), out var modifier))
return modifier;

if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numeric) && Enum.IsDefined(typeof(CriterionModifier), numeric))
return (CriterionModifier)numeric;

throw new JsonException($"Invalid criterion modifier token '{reader.TokenType}'.");
}

public override void Write(Utf8JsonWriter writer, CriterionModifier value, JsonSerializerOptions options)
=> writer.WriteStringValue(JsonNamingPolicy.CamelCase.ConvertName(value.ToString()));

private static bool TryParse(string? value, out CriterionModifier modifier)
{
modifier = default;
if (string.IsNullOrWhiteSpace(value))
return false;

var normalized = Normalize(value);
foreach (var name in Enum.GetNames<CriterionModifier>())
{
if (!string.Equals(Normalize(name), normalized, StringComparison.OrdinalIgnoreCase))
continue;

modifier = Enum.Parse<CriterionModifier>(name);
return true;
}

return false;
}

private static string Normalize(string value)
=> new(value.Where(char.IsLetterOrDigit).ToArray());
}
Loading