feat: establish legacy parity migration baseline
This commit is contained in:
299
src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
Normal file
299
src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
|
||||
public abstract record LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyReadyIntent : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacySearchStocksIntent(string Text, LegacySearchTrigger Trigger)
|
||||
: LegacyUiIntent;
|
||||
|
||||
public sealed record LegacySelectStockIntent(int ResultIndex) : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyCutPointerDownIntent(
|
||||
int PhysicalIndex,
|
||||
bool Control,
|
||||
bool Shift,
|
||||
int X,
|
||||
int Y,
|
||||
long TimestampMilliseconds) : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent;
|
||||
|
||||
public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled)
|
||||
: LegacyUiIntent;
|
||||
|
||||
public sealed record LegacyDismissDialogIntent : LegacyUiIntent;
|
||||
|
||||
public static class LegacyBridgeProtocol
|
||||
{
|
||||
public const string TrustedHost = "legacy-parity.mbn.local";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions();
|
||||
|
||||
public static bool IsTrustedSource(string? source)
|
||||
{
|
||||
if (!Uri.TryCreate(source, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(uri.Host, TrustedHost, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.IsDefaultPort;
|
||||
}
|
||||
|
||||
public static bool TryParseIntent(
|
||||
string json,
|
||||
out LegacyUiIntent? intent,
|
||||
out string? error)
|
||||
{
|
||||
intent = null;
|
||||
error = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
MaxDepth = 8
|
||||
});
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(root, "type", "payload") ||
|
||||
!TryString(root, "type", 64, out var type) ||
|
||||
!root.TryGetProperty("payload", out var payload) ||
|
||||
payload.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
error = "올바르지 않은 UI 요청입니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
intent = type switch
|
||||
{
|
||||
"ready" => ParseReady(payload),
|
||||
"search-stocks" => ParseSearch(payload),
|
||||
"select-stock" => ParseStockSelection(payload),
|
||||
"cut-pointer-down" => ParseCutPointer(payload),
|
||||
"drop-selected-cuts" => ParseDrop(payload),
|
||||
"set-playlist-enabled" => ParseEnabled(payload),
|
||||
"dismiss-dialog" => ParseDismiss(payload),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (intent is null)
|
||||
{
|
||||
error = "지원하지 않는 UI 요청입니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
error = "UI 요청 JSON을 읽을 수 없습니다.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string SerializeState(
|
||||
LegacyOperatorSnapshot snapshot,
|
||||
bool isBusy = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var payload = new
|
||||
{
|
||||
snapshot.Revision,
|
||||
snapshot.SearchText,
|
||||
snapshot.SearchResults,
|
||||
snapshot.SelectedStockIndex,
|
||||
snapshot.CutRows,
|
||||
playlist = snapshot.Playlist.Select(row => new
|
||||
{
|
||||
row.RowId,
|
||||
row.IsEnabled,
|
||||
row.MarketText,
|
||||
row.StockName,
|
||||
row.GraphicType,
|
||||
row.Subtype,
|
||||
row.PageText
|
||||
}),
|
||||
snapshot.Dialog,
|
||||
snapshot.StatusMessage,
|
||||
snapshot.StatusKind,
|
||||
isBusy
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(new { type = "state", payload }, SerializerOptions);
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseReady(JsonElement payload) =>
|
||||
HasOnlyProperties(payload) ? new LegacyReadyIntent() : null;
|
||||
|
||||
private static LegacyUiIntent? ParseSearch(JsonElement payload)
|
||||
{
|
||||
if (!HasOnlyProperties(payload, "text", "trigger") ||
|
||||
!TryString(
|
||||
payload,
|
||||
"text",
|
||||
LegacyParityStockSearchService.LegacyTextBoxMaximumLength,
|
||||
out var text) ||
|
||||
!TryString(payload, "trigger", 16, out var triggerText))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trigger = triggerText switch
|
||||
{
|
||||
"button" => LegacySearchTrigger.Button,
|
||||
"enter" => LegacySearchTrigger.Enter,
|
||||
_ => (LegacySearchTrigger?)null
|
||||
};
|
||||
return trigger.HasValue ? new LegacySearchStocksIntent(text, trigger.Value) : null;
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseStockSelection(JsonElement payload) =>
|
||||
HasOnlyProperties(payload, "resultIndex") &&
|
||||
TryInt32(payload, "resultIndex", 0, int.MaxValue, out var resultIndex)
|
||||
? new LegacySelectStockIntent(resultIndex)
|
||||
: null;
|
||||
|
||||
private static LegacyUiIntent? ParseCutPointer(JsonElement payload)
|
||||
{
|
||||
if (!HasOnlyProperties(
|
||||
payload,
|
||||
"physicalIndex",
|
||||
"control",
|
||||
"shift",
|
||||
"x",
|
||||
"y",
|
||||
"timestampMilliseconds") ||
|
||||
!TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) ||
|
||||
!TryBoolean(payload, "control", out var control) ||
|
||||
!TryBoolean(payload, "shift", out var shift) ||
|
||||
!TryInt32(payload, "x", 0, 16_384, out var x) ||
|
||||
!TryInt32(payload, "y", 0, 16_384, out var y) ||
|
||||
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LegacyCutPointerDownIntent(
|
||||
physicalIndex,
|
||||
control,
|
||||
shift,
|
||||
x,
|
||||
y,
|
||||
timestamp);
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>
|
||||
HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null;
|
||||
|
||||
private static LegacyUiIntent? ParseEnabled(JsonElement payload)
|
||||
{
|
||||
if (!HasOnlyProperties(payload, "rowId", "enabled") ||
|
||||
!TryString(payload, "rowId", 128, out var rowId) ||
|
||||
!IsSafeIdentifier(rowId) ||
|
||||
!TryBoolean(payload, "enabled", out var enabled))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LegacySetPlaylistEnabledIntent(rowId, enabled);
|
||||
}
|
||||
|
||||
private static LegacyUiIntent? ParseDismiss(JsonElement payload) =>
|
||||
HasOnlyProperties(payload) ? new LegacyDismissDialogIntent() : null;
|
||||
|
||||
private static bool HasOnlyProperties(JsonElement element, params string[] names)
|
||||
{
|
||||
var expected = new HashSet<string>(names, StringComparer.Ordinal);
|
||||
var count = 0;
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
count++;
|
||||
if (!expected.Contains(property.Name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return count == expected.Count;
|
||||
}
|
||||
|
||||
private static bool TryString(
|
||||
JsonElement element,
|
||||
string propertyName,
|
||||
int maximumLength,
|
||||
out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
return element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
(value = property.GetString() ?? string.Empty).Length <= maximumLength;
|
||||
}
|
||||
|
||||
private static bool TryBoolean(JsonElement element, string name, out bool value)
|
||||
{
|
||||
value = false;
|
||||
if (!element.TryGetProperty(name, out var property) ||
|
||||
(property.ValueKind != JsonValueKind.True && property.ValueKind != JsonValueKind.False))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = property.GetBoolean();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryInt32(
|
||||
JsonElement element,
|
||||
string name,
|
||||
int minimum,
|
||||
int maximum,
|
||||
out int value)
|
||||
{
|
||||
value = 0;
|
||||
return element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.Number &&
|
||||
property.TryGetInt32(out value) &&
|
||||
value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
private static bool TryInt64(
|
||||
JsonElement element,
|
||||
string name,
|
||||
long minimum,
|
||||
long maximum,
|
||||
out long value)
|
||||
{
|
||||
value = 0;
|
||||
return element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.Number &&
|
||||
property.TryGetInt64(out value) &&
|
||||
value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
private static bool IsSafeIdentifier(string value)
|
||||
{
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.All(character =>
|
||||
char.IsAsciiLetterOrDigit(character) || character is '-' or '_');
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateSerializerOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user