using System.Reflection; using System.Text.Json; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace DorkNet.Server.Binding; /// /// Binds an action's request DTO from either a form-urlencoded body or a JSON /// body, whichever the caller actually sent. /// /// The Rec Room client's HTTP layer (BNDIAONDFFF in RecNet.Runtime) /// sends nearly every write as application/x-www-form-urlencoded, adding /// fields one at a time. Handlers written against [FromBody] only accept /// JSON, so with [ApiController] those requests were rejected with 415 /// Unsupported Media Type before the action body ever ran — this is why every /// club member-management action (invite, ban, kick, promote, approve, modify) /// failed on the 2023 client. /// /// Rather than hand-rolling a form reader per handler, apply this binder to the /// DTO parameter: /// /// /// public Task<IActionResult> MemberBan( /// long clubId, /// [ModelBinder(typeof(FormOrJsonModelBinder))] MemberTargetRequest req) /// /// /// The explicit [ModelBinder] is required: without it [ApiController] /// infers [FromBody] for complex types and this binder never runs. /// /// Matching is case-insensitive on both paths, and a scalar property also /// accepts the singular/plural spellings the client uses interchangeably. A /// missing or unparseable body yields a default-constructed DTO rather than a /// 400, matching how the client treats these calls as fire-and-forget. /// public sealed class FormOrJsonModelBinder : IModelBinder { public async Task BindModelAsync(ModelBindingContext bindingContext) { ArgumentNullException.ThrowIfNull(bindingContext); var type = bindingContext.ModelType; var request = bindingContext.HttpContext.Request; var model = Activator.CreateInstance(type)!; if (request.HasFormContentType) { var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted); foreach (var prop in Writable(type)) { var values = Lookup(form, prop.Name); if (values.Count == 0) continue; var converted = Convert(values, prop.PropertyType); if (converted is not null) prop.SetValue(model, converted); } } else { try { request.EnableBuffering(); request.Body.Position = 0; var parsed = await JsonSerializer.DeserializeAsync( request.Body, type, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, bindingContext.HttpContext.RequestAborted); request.Body.Position = 0; if (parsed is not null) model = parsed; } catch (JsonException) { /* empty or non-JSON body → defaults */ } catch (NotSupportedException) { /* unreadable body → defaults */ } } bindingContext.Result = ModelBindingResult.Success(model); } private static IEnumerable Writable(Type type) => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanWrite); /// Form keys, case-insensitively, also accepting the other /// number of the name — the client says accountId where the DTO says /// AccountIds and vice versa depending on the endpoint. private static List Lookup(IFormCollection form, string propertyName) { var candidates = new List { propertyName }; if (propertyName.EndsWith('s')) candidates.Add(propertyName[..^1]); else candidates.Add(propertyName + "s"); foreach (var candidate in candidates) { foreach (var key in form.Keys) { if (!string.Equals(key, candidate, StringComparison.OrdinalIgnoreCase)) continue; var values = form[key] .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v!) .ToList(); if (values.Count > 0) return values; } } return []; } private static object? Convert(List values, Type target) { var underlying = Nullable.GetUnderlyingType(target) ?? target; if (underlying.IsGenericType && underlying.GetGenericTypeDefinition() == typeof(List<>)) { var element = underlying.GetGenericArguments()[0]; var list = (System.Collections.IList)Activator.CreateInstance(underlying)!; // A repeated field arrives as multiple values; a single field may // still carry a comma-separated list. foreach (var raw in values.SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))) { var item = ConvertScalar(raw, element); if (item is not null) list.Add(item); } return list.Count > 0 ? list : null; } return ConvertScalar(values[0], underlying); } private static object? ConvertScalar(string raw, Type target) { try { if (target == typeof(string)) return raw; if (target == typeof(bool)) return bool.TryParse(raw, out var b) ? b : raw == "1"; if (target.IsEnum) return Enum.TryParse(target, raw, ignoreCase: true, out var e) ? e : null; if (target == typeof(Guid)) return Guid.TryParse(raw, out var g) ? g : null; if (target == typeof(DateTime)) return DateTime.TryParse(raw, out var d) ? d : null; return System.Convert.ChangeType(raw, target, System.Globalization.CultureInfo.InvariantCulture); } catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; } } }