fix: answer 2023 room and playlist mutations with the success/value/error envelope

Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.

RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
This commit is contained in:
Alexa
2026-10-24 23:48:00 +03:00
parent fd2153d82b
commit bf0f09ce84
8 changed files with 294 additions and 18 deletions
@@ -48,6 +48,7 @@ public class PlaylistsController(
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
[HttpPost("/playlists")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> Create()
{
var name = await ReadFieldAsync("name", "Name");
@@ -87,6 +88,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
@@ -96,6 +98,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
@@ -105,6 +108,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
@@ -116,6 +120,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
@@ -131,6 +136,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
@@ -144,6 +150,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
@@ -156,6 +163,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
@@ -174,6 +182,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
@@ -295,6 +304,7 @@ public class PlaylistsController(
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
@@ -307,6 +317,7 @@ public class PlaylistsController(
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
@@ -2912,6 +2912,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
@@ -2967,6 +2968,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
@@ -3616,6 +3618,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
@@ -3658,6 +3661,7 @@ public class RoomsController(
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -3742,6 +3746,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
@@ -3759,6 +3764,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
@@ -3795,6 +3801,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -3894,6 +3901,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -3974,6 +3982,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -4027,6 +4036,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
@@ -4066,6 +4076,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
@@ -4434,6 +4445,7 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> GrantRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
@@ -4456,6 +4468,7 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
@@ -501,6 +501,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/description")]
[HttpPut("roomserver/rooms/{roomId:long}/description")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareDescription(long roomId)
{
// RawValue, not the whitespace-skipping reader: clearing the
@@ -520,6 +521,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/image")]
[HttpPut("roomserver/rooms/{roomId:long}/image")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareImage(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -541,6 +543,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareTags(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -567,6 +570,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
@@ -899,6 +903,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCloning(long roomId)
{
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
@@ -918,6 +923,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAutomute(long roomId)
{
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
@@ -939,6 +945,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareRestrictions(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -965,6 +972,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareWarning(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -983,6 +991,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/loadscreen")]
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
{
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareModify(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1914,7 +1934,13 @@ public class RoomsModerationController(
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
/// reader's dispose-walk NRE'd on the missing nested lists and every
/// mutation reported failure even though it had persisted.</summary>
/// mutation reported failure even though it had persisted.
///
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;</c>, the
/// <c>{success, value, error}</c> envelope reader, so the details are the
/// envelope's <c>value</c>: every caller carries
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
/// envelope the 2023 rooms client reads for every room and playlist mutation.
///
/// <para><b>Client contract.</b> All of these calls go through one generic
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;(string opName, HTTPMethods,
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
/// called as <c>Call 0x1835E50A0</c> 47 times in
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
/// same <c>BDBHCEGKGDC&lt;FGCPNAACHIK&gt;</c> instantiation token
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
/// clone is proven in game to need <c>{success, value, error}</c>
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
/// <c>success</c>, so the helper rejects it with no message and the player sees
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
/// applied the change. The routes are: rooms/{0}/{accessibility,
/// allow_new_users, automute, cloning, comments, creator, description, image,
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
/// name, restrictions, rooms/{1}, tags, warning}.</para>
///
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
/// with a message the client can show, because a non-2xx reply also surfaces
/// only as the message-less CEMNLBKJABA.</para>
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
context.Result = Wrap(context.Result);
base.OnResultExecuting(context);
}
public static IActionResult Wrap(IActionResult result)
{
switch (result)
{
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
return IsEnvelope(obj.Value)
? result
: Success(obj.Value);
case ObjectResult obj:
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
case ForbidResult or ChallengeResult or UnauthorizedResult:
return Failure(DefaultError(403));
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
return Success(null);
case StatusCodeResult code:
return Failure(DefaultError(code.StatusCode));
case EmptyResult:
return Success(null);
default:
return result;
}
}
public static OkObjectResult Success(object? value) =>
new(new { success = true, value, error = string.Empty });
public static OkObjectResult Failure(string error) =>
new(new { success = false, value = (object?)null, error });
private static string DefaultError(int status) => status switch
{
400 => "That request was not valid.",
401 or 403 => "You don't have permission to do that.",
404 => "That room doesn't exist.",
409 => "That name is already taken.",
_ => "Something went wrong.",
};
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
private static string? ErrorText(object? value)
{
if (value is null) return null;
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
try
{
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.String)
{
var text = prop.Value.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
}
catch (NotSupportedException)
{
}
return null;
}
private static bool IsEnvelope(object? value)
{
if (value is null) return false;
var props = value.GetType().GetProperties();
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return Has("success") && Has("value") && Has("error");
}
}