From 1611bf1f5897e692350a45ee172e5c39d858adc8 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 14:48:46 -0400 Subject: [PATCH] Add structured platform API error reasons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + src/MauiDevFlow.Agent.Core/AgentHttpServer.cs | 36 ++++- .../DevFlowAgentService.cs | 145 ++++++++++++++++-- src/MauiDevFlow.Driver/AgentClient.cs | 8 +- .../AgentIntegrationTests.cs | 70 +++++++++ 5 files changed, 240 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a15dc77..87df7e2 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,8 @@ auto-assigned by the broker (range 10223–10899), or configurable via `.mauidev | `/api/platform/permissions` | GET | Check status of all known permissions | | `/api/platform/permissions/{name}` | GET | Check a specific permission status | | `/api/platform/geolocation` | GET | GPS coordinates. `?accuracy=Medium` `?timeout=10` | + +Platform endpoints return structured JSON errors on failure. In addition to `success` and `error`, failures may include a machine-readable `reason` and `details` object so clients can distinguish cases like `missing_permission`, `not_supported`, `main_thread_required`, `timeout`, and `unknown` without parsing the error text. | `/api/sensors` | GET | List all sensors with support/active/subscriber status | | `/api/sensors/{sensor}/start` | POST | Start sensor. `?speed=UI\|Game\|Fastest\|Default` | | `/api/sensors/{sensor}/stop` | POST | Stop sensor | diff --git a/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs b/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs index b33a861..e65e340 100644 --- a/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs +++ b/src/MauiDevFlow.Agent.Core/AgentHttpServer.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; namespace MauiDevFlow.Agent.Core; @@ -467,12 +468,37 @@ public class HttpResponse Body = JsonSerializer.Serialize(new { success = true, message }) }; - public static HttpResponse Error(string message, int statusCode = 400) => new() + public static HttpResponse Error(string message, int statusCode = 400, string? reason = null, object? details = null) { - StatusCode = statusCode, - StatusText = statusCode == 404 ? "Not Found" : "Bad Request", - Body = JsonSerializer.Serialize(new { success = false, error = message }) - }; + var body = new Dictionary + { + ["success"] = false, + ["error"] = message + }; + + if (!string.IsNullOrWhiteSpace(reason)) + body["reason"] = reason; + + if (details != null) + body["details"] = details; + + return new HttpResponse + { + StatusCode = statusCode, + StatusText = statusCode switch + { + 403 => "Forbidden", + 404 => "Not Found", + 408 => "Request Timeout", + 500 => "Internal Server Error", + _ => "Bad Request" + }, + Body = JsonSerializer.Serialize(body, new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }) + }; + } public static HttpResponse NotFound(string message = "Not found") => Error(message, 404); } diff --git a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs index 486def3..67a242a 100644 --- a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using System.Reflection; using System.Runtime.CompilerServices; using Microsoft.Maui; @@ -3830,6 +3831,109 @@ private Task HandleSecureStorageClear(HttpRequest request) // ── Platform info endpoints ── + private const string PlatformErrorReasonMissingPermission = "missing_permission"; + private const string PlatformErrorReasonNotSupported = "not_supported"; + private const string PlatformErrorReasonMainThreadRequired = "main_thread_required"; + private const string PlatformErrorReasonTimeout = "timeout"; + private const string PlatformErrorReasonUnknown = "unknown"; + private const string PlatformErrorReasonInvalidRequest = "invalid_request"; + private static readonly Regex AndroidPermissionRegex = new(@"android\.permission\.[A-Z0-9_\.]+", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static HttpResponse CreatePlatformError(string message, Exception ex, int statusCode = 400, Dictionary? details = null) + { + var payload = BuildPlatformErrorPayload(ex, details); + return HttpResponse.Error(message, payload.StatusCode ?? statusCode, payload.Reason, payload.Details); + } + + private static HttpResponse CreatePlatformError(string message, string reason, int statusCode = 400, Dictionary? details = null) + { + var payloadDetails = CreatePlatformErrorDetails(); + if (details != null) + { + foreach (var (key, value) in details) + { + if (value != null) + payloadDetails[key] = value; + } + } + + return HttpResponse.Error(message, statusCode, reason, payloadDetails.Count > 0 ? payloadDetails : null); + } + + private static (string Reason, Dictionary? Details, int? StatusCode) BuildPlatformErrorPayload( + Exception ex, + Dictionary? details = null) + { + var payloadDetails = CreatePlatformErrorDetails(); + if (details != null) + { + foreach (var (key, value) in details) + { + if (value != null) + payloadDetails[key] = value; + } + } + + if (IsMissingPermissionException(ex)) + { + if (TryExtractPermission(ex.Message) is { Length: > 0 } permission) + payloadDetails["permission"] = permission; + + return (PlatformErrorReasonMissingPermission, payloadDetails.Count > 0 ? payloadDetails : null, 403); + } + + if (IsMainThreadAccessException(ex)) + return (PlatformErrorReasonMainThreadRequired, payloadDetails.Count > 0 ? payloadDetails : null, null); + + if (ex is TimeoutException or TaskCanceledException or OperationCanceledException) + return (PlatformErrorReasonTimeout, payloadDetails.Count > 0 ? payloadDetails : null, 408); + + if (ex is FeatureNotSupportedException or NotSupportedException or PlatformNotSupportedException or FeatureNotEnabledException) + { + if (ex is FeatureNotEnabledException) + payloadDetails["enabled"] = false; + + return (PlatformErrorReasonNotSupported, payloadDetails.Count > 0 ? payloadDetails : null, null); + } + + payloadDetails["exceptionType"] = ex.GetType().Name; + return (PlatformErrorReasonUnknown, payloadDetails, null); + } + + private static Dictionary CreatePlatformErrorDetails() + { + var details = new Dictionary(StringComparer.Ordinal); + try + { + details["platform"] = DeviceInfo.Current.Platform.ToString(); + } + catch + { + } + + return details; + } + + private static bool IsMissingPermissionException(Exception ex) + { + return ex is PermissionException + || AndroidPermissionRegex.IsMatch(ex.Message) + || ex.Message.Contains("AndroidManifest", StringComparison.OrdinalIgnoreCase); + } + + private static string? TryExtractPermission(string message) + { + var match = AndroidPermissionRegex.Match(message); + return match.Success ? match.Value : null; + } + + private static bool IsMainThreadAccessException(Exception ex) + { + return ex.GetType().Name.Equals("UIKitThreadAccessException", StringComparison.Ordinal) + || ex.Message.Contains("main thread", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("UI thread", StringComparison.OrdinalIgnoreCase); + } + private async Task HandlePlatformAppInfo(HttpRequest request) { try @@ -3850,7 +3954,7 @@ private async Task HandlePlatformAppInfo(HttpRequest request) } catch (Exception ex) { - return HttpResponse.Error($"Failed to get app info: {ex.Message}"); + return CreatePlatformError($"Failed to get app info: {ex.Message}", ex); } } @@ -3872,7 +3976,7 @@ private Task HandlePlatformDeviceInfo(HttpRequest request) } catch (Exception ex) { - return Task.FromResult(HttpResponse.Error($"Failed to get device info: {ex.Message}")); + return Task.FromResult(CreatePlatformError($"Failed to get device info: {ex.Message}", ex)); } } @@ -3896,7 +4000,7 @@ private async Task HandlePlatformDeviceDisplay(HttpRequest request } catch (Exception ex) { - return HttpResponse.Error($"Failed to get display info: {ex.Message}"); + return CreatePlatformError($"Failed to get display info: {ex.Message}", ex); } } @@ -3915,7 +4019,7 @@ private Task HandlePlatformBattery(HttpRequest request) } catch (Exception ex) { - return Task.FromResult(HttpResponse.Error($"Failed to get battery info: {ex.Message}")); + return Task.FromResult(CreatePlatformError($"Failed to get battery info: {ex.Message}", ex)); } } @@ -3932,7 +4036,7 @@ private Task HandlePlatformConnectivity(HttpRequest request) } catch (Exception ex) { - return Task.FromResult(HttpResponse.Error($"Failed to get connectivity info: {ex.Message}")); + return Task.FromResult(CreatePlatformError($"Failed to get connectivity info: {ex.Message}", ex)); } } @@ -3958,7 +4062,7 @@ private Task HandlePlatformVersionTracking(HttpRequest request) } catch (Exception ex) { - return Task.FromResult(HttpResponse.Error($"Failed to get version tracking info: {ex.Message}")); + return Task.FromResult(CreatePlatformError($"Failed to get version tracking info: {ex.Message}", ex)); } } @@ -4007,7 +4111,7 @@ private async Task HandlePlatformPermissions(HttpRequest request) } catch (Exception ex) { - return HttpResponse.Error($"Failed to check permissions: {ex.Message}"); + return CreatePlatformError($"Failed to check permissions: {ex.Message}", ex); } } @@ -4016,10 +4120,16 @@ private async Task HandlePlatformPermissionCheck(HttpRequest reque try { if (!request.RouteParams.TryGetValue("permission", out var permName)) - return HttpResponse.Error("permission name is required"); + return HttpResponse.Error( + "permission name is required", + reason: PlatformErrorReasonInvalidRequest, + details: new Dictionary { ["parameter"] = "permission" }); if (!KnownPermissions.TryGetValue(permName, out var factory)) - return HttpResponse.Error($"Unknown permission: {permName}. Valid: {string.Join(", ", KnownPermissions.Keys)}"); + return HttpResponse.Error( + $"Unknown permission: {permName}. Valid: {string.Join(", ", KnownPermissions.Keys)}", + reason: PlatformErrorReasonInvalidRequest, + details: new Dictionary { ["parameter"] = "permission" }); var perm = factory(); var status = await perm.CheckStatusAsync(); @@ -4027,7 +4137,7 @@ private async Task HandlePlatformPermissionCheck(HttpRequest reque } catch (Exception ex) { - return HttpResponse.Error($"Failed to check permission: {ex.Message}"); + return CreatePlatformError($"Failed to check permission: {ex.Message}", ex); } } @@ -4051,7 +4161,7 @@ private async Task HandlePlatformGeolocation(HttpRequest request) var location = await Geolocation.GetLocationAsync(new GeolocationRequest(accuracy, TimeSpan.FromSeconds(timeoutSec))); if (location == null) - return HttpResponse.Error("Could not determine location"); + return CreatePlatformError("Could not determine location", PlatformErrorReasonUnknown); return HttpResponse.Json(new { @@ -4067,15 +4177,22 @@ private async Task HandlePlatformGeolocation(HttpRequest request) } catch (PermissionException) { - return HttpResponse.Error("Location permission not granted", 403); + return CreatePlatformError("Location permission not granted", PlatformErrorReasonMissingPermission, 403); } catch (FeatureNotEnabledException) { - return HttpResponse.Error("Location services not enabled on device"); + return CreatePlatformError( + "Location services not enabled on device", + PlatformErrorReasonNotSupported, + details: new Dictionary + { + ["feature"] = "geolocation", + ["enabled"] = false + }); } catch (Exception ex) { - return HttpResponse.Error($"Failed to get location: {ex.Message}"); + return CreatePlatformError($"Failed to get location: {ex.Message}", ex); } } diff --git a/src/MauiDevFlow.Driver/AgentClient.cs b/src/MauiDevFlow.Driver/AgentClient.cs index 544902b..e4fcb1e 100644 --- a/src/MauiDevFlow.Driver/AgentClient.cs +++ b/src/MauiDevFlow.Driver/AgentClient.cs @@ -315,8 +315,12 @@ private async Task GetJsonAsync(string path) { try { - var response = await _http.GetStringAsync($"{_baseUrl}{path}"); - return JsonSerializer.Deserialize(response); + using var response = await _http.GetAsync($"{_baseUrl}{path}"); + var body = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(body)) + return default; + + return JsonSerializer.Deserialize(body); } catch { return default; } } diff --git a/tests/MauiDevFlow.Tests/AgentIntegrationTests.cs b/tests/MauiDevFlow.Tests/AgentIntegrationTests.cs index 0cc6b77..c97f112 100644 --- a/tests/MauiDevFlow.Tests/AgentIntegrationTests.cs +++ b/tests/MauiDevFlow.Tests/AgentIntegrationTests.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; +using MauiDevFlow.Agent.Core; namespace MauiDevFlow.Tests; @@ -199,5 +200,74 @@ public async Task TreeEndpoint_ParsesNestedElements() listener.Stop(); } + [Fact] + public void HttpResponseError_IncludesReasonAndDetails_WhenProvided() + { + var response = HttpResponse.Error( + "Failed to get battery info", + 403, + "missing_permission", + new Dictionary + { + ["permission"] = "android.permission.BATTERY_STATS", + ["platform"] = "Android" + }); + + Assert.Equal(403, response.StatusCode); + Assert.Equal("Forbidden", response.StatusText); + Assert.NotNull(response.Body); + + var json = JsonSerializer.Deserialize(response.Body!); + Assert.False(json.GetProperty("success").GetBoolean()); + Assert.Equal("Failed to get battery info", json.GetProperty("error").GetString()); + Assert.Equal("missing_permission", json.GetProperty("reason").GetString()); + Assert.Equal("android.permission.BATTERY_STATS", json.GetProperty("details").GetProperty("permission").GetString()); + Assert.Equal("Android", json.GetProperty("details").GetProperty("platform").GetString()); + } + + [Fact] + public async Task GetPlatformInfoAsync_ReturnsStructuredErrorBody_OnNonSuccessResponse() + { + using var listener = new TcpListener(IPAddress.Loopback, _port); + listener.Start(); + + var acceptTask = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(); + using var stream = client.GetStream(); + var buffer = new byte[4096]; + var read = await stream.ReadAsync(buffer); + var request = Encoding.UTF8.GetString(buffer, 0, read); + + Assert.Contains("GET /api/platform/battery", request); + + var body = """ + { + "success": false, + "error": "Failed to get battery info: You need to declare using the permission: `android.permission.BATTERY_STATS` in your AndroidManifest.xml", + "reason": "missing_permission", + "details": { + "permission": "android.permission.BATTERY_STATS", + "platform": "Android" + } + } + """; + var response = $"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {Encoding.UTF8.GetByteCount(body)}\r\nConnection: close\r\n\r\n{body}"; + await stream.WriteAsync(Encoding.UTF8.GetBytes(response)); + }); + + using var agentClient = new MauiDevFlow.Driver.AgentClient("localhost", _port); + var result = await agentClient.GetPlatformInfoAsync("battery"); + + Assert.Equal(JsonValueKind.Object, result.ValueKind); + Assert.False(result.GetProperty("success").GetBoolean()); + Assert.Equal("missing_permission", result.GetProperty("reason").GetString()); + Assert.Equal("android.permission.BATTERY_STATS", result.GetProperty("details").GetProperty("permission").GetString()); + Assert.Equal("Android", result.GetProperty("details").GetProperty("platform").GetString()); + + await acceptTask; + listener.Stop(); + } + public void Dispose() { } }