diff --git a/.gitignore b/.gitignore index c6dbf0fb..d6151772 100644 --- a/.gitignore +++ b/.gitignore @@ -364,3 +364,8 @@ FodyWeavers.xsd .idea .idea/* + +# Local tool artifacts +.build-timestamp +config.json +local-nuget/ diff --git a/README.md b/README.md index 2ff42a6e..c26bb18c 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,103 @@ Turning on *SafeMode* will make the client before the first request to do an int schema will be used to make sure that any auto included properties are available. This is an advanced feature that require the endpoint to support introspection. By default safe mode is turned of. +# Error Handling + +## Throwing Behavior (Default) + +By default, `ExecuteAsync` throws `GraphQueryExecutionException` when the GraphQL response contains errors: + +```cs +try +{ + var customer = await sampleClient + .Query + .Customer(id: "abc-123") + .Select(e => e) + .ExecuteAsync(); +} +catch (GraphQueryExecutionException ex) +{ + foreach (var error in ex.Errors) + { + Console.WriteLine($"Error: {error.Message}"); + Console.WriteLine($"Code: {error.ErrorCode}"); + } +} +catch (GraphQueryRequestException ex) +{ + Console.WriteLine($"HTTP error: {ex.Message}"); +} +``` + +`GraphQueryExecutionException` provides: +- **Errors** — list of `GraphQueryError` with `Message`, `Locations`, `Path`, `Extensions` +- **ErrorCode** — classified error code (Authentication, Forbidden, Validation, BadRequest, etc.) +- **Extensions** — full error extensions from the server (custom codes, status codes, etc.) +- **GraphQLQuery** / **GraphQLVariables** — the request that caused the error + +## Result API (No Throw) + +Use `ExecuteWithResultAsync` to get both data and errors without exceptions: + +```cs +var result = await sampleClient + .Query + .Customer(id: "abc-123") + .Select(e => e) + .ExecuteWithResultAsync(); + +if (result.HasErrors) +{ + foreach (var error in result.Errors) + { + Console.WriteLine($"{error.ErrorCode}: {error.Message}"); + } +} + +if (result.HasData) +{ + Console.WriteLine(result.Data.CustomerName); +} +``` + +`GraphResult` provides: +- **Data** — the response data (may be present even with partial errors) +- **Errors** — list of `GraphQueryError` +- **Extensions** — response-level extensions from the server +- **HasErrors** / **HasData** — quick checks +- **EnsureNoErrors()** — throws if errors exist (opt-in to throwing) + +## Error Codes + +`GraphQueryError.ErrorCode` classifies known error codes from popular GraphQL servers: + +| Code | Enum | +|------|------| +| `UNAUTHENTICATED` | `GraphErrorCode.Authentication` | +| `FORBIDDEN` | `GraphErrorCode.Forbidden` | +| `BAD_USER_INPUT` | `GraphErrorCode.BadRequest` | +| `GRAPHQL_VALIDATION_FAILED` | `GraphErrorCode.Validation` | +| `INTERNAL_SERVER_ERROR` | `GraphErrorCode.InternalServerError` | +| `RATE_LIMITED` | `GraphErrorCode.RateLimited` | +| `TIMEOUT` | `GraphErrorCode.Timeout` | + +Unrecognized codes return `GraphErrorCode.Unknown`. + +## Cursor Paging + +`NextPageWithResultAsync` and `PreviousPageWithResultAsync` follow the same pattern: + +```cs +var pager = sampleClient + .Query + .Orders(first: 10) + .AsPager(); + +var page = await pager.NextPageWithResultAsync(); +if (page.HasErrors) { /* handle */ } +``` + # Acknowledgments Linq2GraphQL is inspired by [GraphQLinq](https://github.com/Giorgi/GraphQLinq) , thank diff --git a/docs/error-handling-fix-plan.md b/docs/error-handling-fix-plan.md new file mode 100644 index 00000000..cfecfe26 --- /dev/null +++ b/docs/error-handling-fix-plan.md @@ -0,0 +1,85 @@ +# Error Handling Fix Plan — Linq2GraphQL.Client + +## Overview +Fix 6 GraphQL error handling deficiencies. 4 phases, 12 steps. Each step leaves codebase buildable and backward-compatible. + +## Phase 1: Foundation (Issues 1, 6, 3) — Data model changes only + +### Step 1.1: Add `Extensions` property to `GraphQueryError` +- **File**: `src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs` +- **Action**: Add `[JsonPropertyName("extensions")] public Dictionary? Extensions { get; set; }` to `GraphQueryError` +- **Effort**: 2 min | **Risk**: Low | **BC**: 100% + +### Step 1.2: Create `GraphErrorCode` enum + classifier +- **New File**: `src/Linq2GraphQL.Client/Exceptions/GraphErrorCode.cs` +- **Action**: Enum with standard codes (UNAUTHENTICATED, FORBIDDEN, VALIDATION, etc.) + `GraphErrorCodeClassifier` static helper +- **Also**: Add `ErrorCode` computed property to `GraphQueryError` +- **Effort**: 10 min | **Risk**: Low | **BC**: 100% + +### Step 1.3: Create `GraphResult` wrapper +- **New File**: `src/Linq2GraphQL.Client/GraphResult.cs` +- **Action**: `GraphResult` with `Data`, `Errors`, `Extensions`, `HasErrors`, `HasData` +- **Effort**: 5 min | **Risk**: Low | **BC**: 100% + +## Phase 2: Core Query/Mutation Error Handling (Issues 2, 3) + +### Step 2.1: Refactor `QueryExecutor` — internal `ProcessResponseFull` +- **File**: `src/Linq2GraphQL.Client/QueryExecutor.cs` +- **Action**: New `internal GraphResult ProcessResponseFull(...)` that parses data+errors+extensions. Keep existing `ProcessResponse` as backward-compat wrapper. +- **Effort**: 15 min | **Risk**: Medium | **BC**: 100% + +### Step 2.2: Add `ExecuteWithResultAsync` to `GraphQueryExecute` +- **File**: `src/Linq2GraphQL.Client/GraphQueryExecute.cs` + `QueryExecutor.cs` +- **Action**: Add `ExecuteRawAsync` to QueryExecutor, add `ExecuteBaseWithResultAsync` + `ExecuteWithResultAsync` to GraphQueryExecute +- **Effort**: 15 min | **Risk**: Medium | **BC**: 100% + +### Step 2.3: Add `ExecuteWithResultAsync` to cursor paging +- **File**: `src/Linq2GraphQL.Client/GraphQueryExecute.cs` + `GraphCursorPager.cs` +- **Action**: Same pattern for `GraphCursorQueryExecute` and pager +- **Effort**: 10 min | **Risk**: Low | **BC**: 100% + +## Phase 3: Subscription Error Handling (Issues 4, 5) + +### Step 3.1: Handle WS `type: "error"` and `type: "complete"` messages +- **Files**: `WebsocketRequestTypes.cs`, `WebsocketResponse.cs`, `WSClient.cs` +- **Action**: Add message type constants, handle error/complete in WSClient message routing, propagate via OnError/OnCompleted +- **Effort**: 25 min | **Risk**: Medium | **BC**: Behavioral fix + +### Step 3.2: Add error resilience to subscription pipeline +- **File**: `GraphSubscriptionExecute.cs` +- **Action**: Replace `Select` with `SelectMany` + try/catch — skip bad messages, don't kill stream +- **Effort**: 15 min | **Risk**: Medium | **BC**: Behavioral fix + +### Step 3.3: SSE client error handling +- **File**: `SSEClient.cs` +- **Action**: Wrap HTTP errors in `GraphQueryRequestException`, null checks, parse `event: error` SSE frames +- **Effort**: 10 min | **Risk**: Low | **BC**: 100% + +## Phase 4: Testing & Verification + +### Step 4.1: Add error handling unit tests +- **New File**: `test/Linq2GraphQL.Tests/ErrorHandlingTests.cs` +- **Action**: Test extensions deserialization, GraphResult partial data, error codes, subscription error resilience +- **Effort**: 20 min | **Risk**: Low + +### Step 4.2: Run full test suite +- **Action**: `dotnet build` + `dotnet test` +- **Effort**: 5 min + +## Dependency Graph +``` +1.1 → 1.2 → 1.3 → 2.1 → 2.2 → 2.3 + ↘ +1.1 → 3.1 → 3.2 + ↘ 3.3 +→ 4.1 → 4.2 +``` + +## Risk Summary +| Step | Risk | Mitigation | +|------|------|------------| +| 1.1-1.3 | Low | Additive only, no behavioral change | +| 2.1-2.3 | Medium | Keep old APIs, add new opt-in APIs | +| 3.1-3.2 | Medium | Test with both WS protocols | +| 3.3 | Low | Defensive coding | +| 4.1-4.2 | Low | Tests are additive | diff --git a/src/Linq2GraphQL.Client.Subscriptions/GraphSubscriptionExecute.cs b/src/Linq2GraphQL.Client.Subscriptions/GraphSubscriptionExecute.cs index c27a4651..84ecca77 100644 --- a/src/Linq2GraphQL.Client.Subscriptions/GraphSubscriptionExecute.cs +++ b/src/Linq2GraphQL.Client.Subscriptions/GraphSubscriptionExecute.cs @@ -1,4 +1,5 @@ -using System.Linq.Expressions; +using System.Diagnostics; +using System.Linq.Expressions; using System.Reactive.Linq; namespace Linq2GraphQL.Client.Subscriptions; @@ -23,11 +24,30 @@ public async Task> StartAsync() #pragma warning disable CS4014 Task.Run(sseClient.Start); #pragma warning restore CS4014 - return sseClient.Subscription.Select(e => ConvertResult(queryExecutor.ProcessResponse(e, QueryNode.Name, request))); + return sseClient.Subscription.SelectMany(json => SafeProcessMessage(json, request)); } var wsClient = new WSClient(client, request); await wsClient.Start(); - return wsClient.Subscription.Select(e => ConvertResult(queryExecutor.ProcessResponse(e, QueryNode.Name, request))); + return wsClient.Subscription.SelectMany(json => SafeProcessMessage(json, request)); + } + + private IEnumerable SafeProcessMessage(string json, GraphQLRequest request) + { + if (string.IsNullOrWhiteSpace(json)) + { + return Array.Empty(); + } + + try + { + var result = queryExecutor.ProcessResponse(json, QueryNode.Name, request); + return new[] { ConvertResult(result) }; + } + catch (Exception ex) + { + Debug.WriteLine($"Subscription message error: {ex.Message}"); + return Array.Empty(); + } } } \ No newline at end of file diff --git a/src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs b/src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs index 79f971a5..da767f32 100644 --- a/src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs +++ b/src/Linq2GraphQL.Client.Subscriptions/SSEClient.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Net.Mime; using System.Reactive.Linq; using System.Reactive.Subjects; @@ -12,6 +12,7 @@ public class SSEClient : IDisposable private readonly GraphClient graphClient; private readonly GraphQLRequest payload; private readonly Subject subscriptionSubject = new(); + private readonly Subject errorSubject = new(); private HttpResponseMessage response; private StreamReader streamReader; @@ -22,6 +23,7 @@ public SSEClient(GraphClient graphClient, GraphQLRequest payload) } public IObservable Subscription => subscriptionSubject.AsObservable(); + public IObservable Errors => errorSubject.AsObservable(); public void Dispose() { @@ -39,8 +41,25 @@ public async Task Start() }; request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); - response = await graphClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); + + try + { + response = await graphClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + } + catch (HttpRequestException ex) + { + throw new GraphQueryRequestException( + $"SSE connection failed: {ex.Message}", + payload.Query, payload.Variables); + } + + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + throw new GraphQueryRequestException( + $"SSE connection failed with status {response.StatusCode}: {content}", + payload.Query, payload.Variables); + } streamReader = new StreamReader(await response.Content.ReadAsStreamAsync()); @@ -48,11 +67,23 @@ public async Task Start() { var message = await streamReader.ReadLineAsync(); + if (message == null) continue; + if (message.StartsWith("data: ")) { var jsonData = message.Substring(6); subscriptionSubject.OnNext(jsonData); } + else if (message.StartsWith("event: error")) + { + var errorData = await streamReader.ReadLineAsync(); + if (errorData != null && errorData.StartsWith("data: ")) + { + var errorJson = errorData.Substring(6); + var errors = new List { new() { Message = errorJson } }; + errorSubject.OnNext(new GraphQueryExecutionException(errors, payload.Query, payload.Variables)); + } + } } } } \ No newline at end of file diff --git a/src/Linq2GraphQL.Client.Subscriptions/WSClient.cs b/src/Linq2GraphQL.Client.Subscriptions/WSClient.cs index af0fdb1a..5a79ac2b 100644 --- a/src/Linq2GraphQL.Client.Subscriptions/WSClient.cs +++ b/src/Linq2GraphQL.Client.Subscriptions/WSClient.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Net.WebSockets; using System.Reactive.Linq; using System.Reactive.Subjects; @@ -14,6 +14,7 @@ public class WSClient : IAsyncDisposable private readonly GraphQLRequest payload; private readonly Subject subscriptionSubject = new(); + private readonly Subject errorSubject = new(); private readonly WebsocketClient client; private readonly JsonSerializerOptions jsonOptions; @@ -41,6 +42,7 @@ public WSClient(GraphClient graphClient, GraphQLRequest payload) } public IObservable Subscription => subscriptionSubject.AsObservable(); + public IObservable Errors => errorSubject.AsObservable(); public async ValueTask DisposeAsync() @@ -67,7 +69,30 @@ public async Task Start() tt.Where(e => e.Type == WebsocketRequestTypes.PING).Subscribe(msg => SendRequest(new WebsocketRequest(WebsocketRequestTypes.PONG))); - tt.Where(e => !string.IsNullOrEmpty(e?.Id)).Subscribe(r => + tt.Where(e => e.Type == WebsocketRequestTypes.ERROR).Subscribe(r => + { + LogMessage($"Subscription error received: {r.Payload}"); + var errors = r.Payload is JsonElement payloadEl && payloadEl.ValueKind == JsonValueKind.Object + && payloadEl.TryGetProperty("errors", out var errorsEl) + ? errorsEl.Deserialize>(_graphClient.SerializerOptions) + : new List { new() { Message = r.Payload?.ToString() ?? "Unknown subscription error" } }; + errorSubject.OnNext(new GraphQueryExecutionException(errors, string.Empty, null)); + }); + + tt.Where(e => e.Type == WebsocketRequestTypes.COMPLETE).Subscribe(r => + { + LogMessage($"Subscription completed for id: {r.Id}"); + subscriptionSubject.OnCompleted(); + errorSubject.OnCompleted(); + }); + + tt.Where(e => !string.IsNullOrEmpty(e?.Id) + && e.Type != WebsocketRequestTypes.ERROR + && e.Type != WebsocketRequestTypes.COMPLETE + && e.Type != WebsocketRequestTypes.PING + && e.Type != WebsocketRequestTypes.PONG + && e.Type != WebsocketRequestTypes.CONNECTION_ACK + && e.Type != WebsocketRequestTypes.CONNECTION_INIT).Subscribe(r => { subscriptionSubject.OnNext(r.Payload?.ToString()); }); diff --git a/src/Linq2GraphQL.Client.Subscriptions/WebsocketRequestTypes.cs b/src/Linq2GraphQL.Client.Subscriptions/WebsocketRequestTypes.cs index 5bb6bc1a..5d57f607 100644 --- a/src/Linq2GraphQL.Client.Subscriptions/WebsocketRequestTypes.cs +++ b/src/Linq2GraphQL.Client.Subscriptions/WebsocketRequestTypes.cs @@ -1,10 +1,12 @@ -namespace Linq2GraphQL.Client.Subscriptions +namespace Linq2GraphQL.Client.Subscriptions { internal class WebsocketRequestTypes { internal const string PING = "ping"; internal const string PONG = "pong"; internal const string CONNECTION_INIT = "connection_init"; - + internal const string CONNECTION_ACK = "connection_ack"; + internal const string ERROR = "error"; + internal const string COMPLETE = "complete"; } } diff --git a/src/Linq2GraphQL.Client/Assembly.cs b/src/Linq2GraphQL.Client/Assembly.cs index 78355a9a..b2d3a8cf 100644 --- a/src/Linq2GraphQL.Client/Assembly.cs +++ b/src/Linq2GraphQL.Client/Assembly.cs @@ -1,3 +1,5 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Linq2GraphQL.Generator")] \ No newline at end of file +[assembly: InternalsVisibleTo("Linq2GraphQL.Generator")] +[assembly: InternalsVisibleTo("Linq2GraphQL.Tests")] +[assembly: InternalsVisibleTo("Linq2GraphQL.Client.Subscriptions")] \ No newline at end of file diff --git a/src/Linq2GraphQL.Client/Exceptions/GraphErrorCode.cs b/src/Linq2GraphQL.Client/Exceptions/GraphErrorCode.cs new file mode 100644 index 00000000..c7507a4c --- /dev/null +++ b/src/Linq2GraphQL.Client/Exceptions/GraphErrorCode.cs @@ -0,0 +1,55 @@ +namespace Linq2GraphQL.Client; + +public enum GraphErrorCode +{ + Unknown, + Authentication, + Authorization, + Forbidden, + Validation, + BadRequest, + NotFound, + RateLimited, + InternalServerError, + Timeout, + Conflict, + PersistedQueryNotFound, + PersistedQueryMismatch +} + +internal static class GraphErrorCodeClassifier +{ + private static readonly Dictionary CodeMap = new(StringComparer.OrdinalIgnoreCase) + { + // Apollo / GraphQL-over-HTTP codes + { "UNAUTHENTICATED", GraphErrorCode.Authentication }, + { "FORBIDDEN", GraphErrorCode.Forbidden }, + { "BAD_USER_INPUT", GraphErrorCode.BadRequest }, + { "VALIDATION_FAILED", GraphErrorCode.Validation }, + { "GRAPHQL_VALIDATION_FAILED", GraphErrorCode.Validation }, + { "INTERNAL_SERVER_ERROR", GraphErrorCode.InternalServerError }, + { "PERSISTED_QUERY_NOT_FOUND", GraphErrorCode.PersistedQueryNotFound }, + { "PERSISTED_QUERY_NOT_SUPPORTED", GraphErrorCode.PersistedQueryMismatch }, + // Hot Chocolate codes + { "AUTH_NOT_AUTHENTICATED", GraphErrorCode.Authentication }, + { "AUTH_NOT_AUTHORIZED", GraphErrorCode.Authorization }, + { "HC0018", GraphErrorCode.Validation }, + { "HC0008", GraphErrorCode.BadRequest }, + { "HC0012", GraphErrorCode.NotFound }, + { "HC0001", GraphErrorCode.InternalServerError }, + // Common patterns + { "RATE_LIMITED", GraphErrorCode.RateLimited }, + { "RATE_LIMIT_EXCEEDED", GraphErrorCode.RateLimited }, + { "THROTTLED", GraphErrorCode.RateLimited }, + { "TIMEOUT", GraphErrorCode.Timeout }, + { "REQUEST_TIMEOUT", GraphErrorCode.Timeout }, + { "CONFLICT", GraphErrorCode.Conflict }, + { "NOT_FOUND", GraphErrorCode.NotFound }, + }; + + internal static GraphErrorCode Classify(string code) + { + if (string.IsNullOrWhiteSpace(code)) return GraphErrorCode.Unknown; + return CodeMap.TryGetValue(code, out var errorCode) ? errorCode : GraphErrorCode.Unknown; + } +} \ No newline at end of file diff --git a/src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs b/src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs index a3f85b0e..8ada5034 100644 --- a/src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs +++ b/src/Linq2GraphQL.Client/Exceptions/GraphQueryExecutionException.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Linq2GraphQL.Client; @@ -33,6 +33,13 @@ public class GraphQueryError [JsonPropertyName("locations")] public ErrorLocation[] Locations { get; set; } [JsonPropertyName("path")] public List Path { get; set; } + + [JsonPropertyName("extensions")] public Dictionary Extensions { get; set; } + + public GraphErrorCode ErrorCode => + Extensions?.TryGetValue("code", out var codeObj) == true + ? GraphErrorCodeClassifier.Classify(codeObj?.ToString()) + : GraphErrorCode.Unknown; } public class ErrorLocation diff --git a/src/Linq2GraphQL.Client/GraphBaseExecute.cs b/src/Linq2GraphQL.Client/GraphBaseExecute.cs index dbc3454d..6ea4a0d1 100644 --- a/src/Linq2GraphQL.Client/GraphBaseExecute.cs +++ b/src/Linq2GraphQL.Client/GraphBaseExecute.cs @@ -1,4 +1,4 @@ -using Linq2GraphQL.Client.Schema; +using Linq2GraphQL.Client.Schema; using System.Linq.Expressions; using System.Text.Json; @@ -123,4 +123,14 @@ public TResult ConvertResult(T result) return mapper.Invoke(result); } } + + public GraphResult ConvertResultFull(GraphResult result) + { + return new GraphResult + { + Data = ConvertResult(result.Data), + Errors = result.Errors, + Extensions = result.Extensions + }; + } } diff --git a/src/Linq2GraphQL.Client/GraphClient.cs b/src/Linq2GraphQL.Client/GraphClient.cs index e5f2925d..a8512ab0 100644 --- a/src/Linq2GraphQL.Client/GraphClient.cs +++ b/src/Linq2GraphQL.Client/GraphClient.cs @@ -14,6 +14,18 @@ public class GraphClient private readonly IOptions options; private readonly bool includeDeprecated; + /// + /// Constructor for backward compatibility with code compiled against 0.13.83 and earlier. + /// This constructor calls the 4-parameter constructor with includeDeprecated = false. + /// + public GraphClient(HttpClient httpClient, IOptions options, IServiceProvider provider) + : this(httpClient, options, provider, false) + { + } + + /// + /// Constructor with optional includeDeprecated parameter. + /// public GraphClient(HttpClient httpClient, IOptions options, IServiceProvider provider, bool includeDeprecated = false) { diff --git a/src/Linq2GraphQL.Client/GraphCursorPager.cs b/src/Linq2GraphQL.Client/GraphCursorPager.cs index ec62220d..eb173a9b 100644 --- a/src/Linq2GraphQL.Client/GraphCursorPager.cs +++ b/src/Linq2GraphQL.Client/GraphCursorPager.cs @@ -1,4 +1,4 @@ -using Linq2GraphQL.Client.Common; +using Linq2GraphQL.Client.Common; using System.Linq.Expressions; namespace Linq2GraphQL.Client; @@ -27,6 +27,12 @@ private async Task ExecutePagerAsync(CancellationToken cancellationToke return query.ConvertResult(baseType); } + private async Task> ExecutePagerWithResultAsync(CancellationToken cancellationToken = default) + { + var result = await query.ExecuteBaseWithResultAsync(cancellationToken); + return query.ConvertResultFull(result); + } + public async Task NextPageAsync(CancellationToken cancellationToken = default) { query.QueryNode.SetArgumentValue("after", query.BaseResult?.PageInfo?.EndCursor); @@ -34,10 +40,24 @@ public async Task NextPageAsync(CancellationToken cancellationToken = d return await ExecutePagerAsync(cancellationToken); } + public async Task> NextPageWithResultAsync(CancellationToken cancellationToken = default) + { + query.QueryNode.SetArgumentValue("after", query.BaseResult?.PageInfo?.EndCursor); + query.QueryNode.SetArgumentValue("before", null); + return await ExecutePagerWithResultAsync(cancellationToken); + } + public async Task PreviousPageAsync(CancellationToken cancellationToken = default) { query.QueryNode.SetArgumentValue("after", null); query.QueryNode.SetArgumentValue("before", query.BaseResult?.PageInfo?.EndCursor); return await ExecutePagerAsync(cancellationToken); } + + public async Task> PreviousPageWithResultAsync(CancellationToken cancellationToken = default) + { + query.QueryNode.SetArgumentValue("after", null); + query.QueryNode.SetArgumentValue("before", query.BaseResult?.PageInfo?.EndCursor); + return await ExecutePagerWithResultAsync(cancellationToken); + } } diff --git a/src/Linq2GraphQL.Client/GraphQueryExecute.cs b/src/Linq2GraphQL.Client/GraphQueryExecute.cs index 66e8f0ac..d7cf29a4 100644 --- a/src/Linq2GraphQL.Client/GraphQueryExecute.cs +++ b/src/Linq2GraphQL.Client/GraphQueryExecute.cs @@ -1,4 +1,4 @@ -using Linq2GraphQL.Client.Common; +using Linq2GraphQL.Client.Common; using System.Linq.Expressions; namespace Linq2GraphQL.Client; @@ -28,8 +28,20 @@ public async Task ExecuteBaseAsync(CancellationToken cancellationToken = defa return BaseResult; } + public async Task> ExecuteBaseWithResultAsync(CancellationToken cancellationToken = default) + { + var result = await queryExecutor.ExecuteRawAsync(QueryNode.Name, await GetRequestAsync(), cancellationToken); + BaseResult = result.Data; + return result; + } + public async Task ExecuteAsync(CancellationToken cancellationToken = default) { return ConvertResult(await ExecuteBaseAsync(cancellationToken)); } + + public async Task> ExecuteWithResultAsync(CancellationToken cancellationToken = default) + { + return ConvertResultFull(await ExecuteBaseWithResultAsync(cancellationToken)); + } } \ No newline at end of file diff --git a/src/Linq2GraphQL.Client/GraphResult.cs b/src/Linq2GraphQL.Client/GraphResult.cs new file mode 100644 index 00000000..2357c47b --- /dev/null +++ b/src/Linq2GraphQL.Client/GraphResult.cs @@ -0,0 +1,19 @@ +namespace Linq2GraphQL.Client; + +public class GraphResult +{ + public T Data { get; init; } + public List Errors { get; init; } + public Dictionary Extensions { get; init; } + + public bool HasErrors => Errors is { Count: > 0 }; + public bool HasData => Data is not null; + + public void EnsureNoErrors() + { + if (HasErrors) + { + throw new GraphQueryExecutionException(Errors, string.Empty, null); + } + } +} \ No newline at end of file diff --git a/src/Linq2GraphQL.Client/QueryExecutor.cs b/src/Linq2GraphQL.Client/QueryExecutor.cs index 28d46a0b..c3fbf9f2 100644 --- a/src/Linq2GraphQL.Client/QueryExecutor.cs +++ b/src/Linq2GraphQL.Client/QueryExecutor.cs @@ -1,4 +1,4 @@ -using System.Net.Http.Json; +using System.Net.Http.Json; using System.Text.Json; namespace Linq2GraphQL.Client; @@ -18,6 +18,19 @@ internal QueryExecutor(GraphClient client) internal async Task ExecuteRequestAsync(string name, GraphQLRequest graphRequest, CancellationToken cancellationToken = default) + { + var result = await ExecuteRawAsync(name, graphRequest, cancellationToken); + + if (result.HasErrors) + { + throw new GraphQueryExecutionException(result.Errors, graphRequest.Query, graphRequest.Variables); + } + + return result.Data; + } + + internal async Task> ExecuteRawAsync(string name, GraphQLRequest graphRequest, + CancellationToken cancellationToken = default) { using var response = await client.HttpClient.PostAsJsonAsync("", graphRequest, client.SerializerOptions, cancellationToken: cancellationToken); @@ -30,28 +43,52 @@ internal async Task ExecuteRequestAsync(string name, GraphQLRequest graphRequ } var con = await response.Content.ReadAsStringAsync(cancellationToken); - return ProcessResponse(con, name, graphRequest); + return ProcessResponseFull(con, name); } public T ProcessResponse(string con, string name, GraphQLRequest request) + { + var result = ProcessResponseFull(con, name); + + if (result.HasErrors) + { + throw new GraphQueryExecutionException(result.Errors, request.Query, request.Variables); + } + + return result.Data; + } + + public GraphResult ProcessResponseFull(string con, string name) { var document = JsonDocument.Parse(con); - var hasError = document.RootElement.TryGetProperty(ErrorPropertyName, out var errorElement); + var root = document.RootElement; - if (hasError) + List errors = null; + if (root.TryGetProperty(ErrorPropertyName, out var errorElement)) { - var errors = errorElement.Deserialize>(client.SerializerOptions); - throw new GraphQueryExecutionException(errors, request.Query, request.Variables); + errors = errorElement.Deserialize>(client.SerializerOptions); } - document.RootElement.TryGetProperty(DataPropertyName, out var dataElement); - dataElement.TryGetProperty(name, out var resultElement); + T data = default; + if (root.TryGetProperty(DataPropertyName, out var dataElement) && dataElement.ValueKind != JsonValueKind.Null) + { + if (dataElement.TryGetProperty(name, out var resultElement) && resultElement.ValueKind != JsonValueKind.Null) + { + data = resultElement.Deserialize(client.SerializerOptions); + } + } - if (resultElement.ValueKind == JsonValueKind.Null) + Dictionary extensions = null; + if (root.TryGetProperty(ExtensionsPropertyName, out var extensionsElement)) { - return default; + extensions = extensionsElement.Deserialize>(client.SerializerOptions); } - return resultElement.Deserialize(client.SerializerOptions); + return new GraphResult + { + Data = data, + Errors = errors, + Extensions = extensions + }; } } \ No newline at end of file diff --git a/test/Linq2GraphQL.TestServer/Query.cs b/test/Linq2GraphQL.TestServer/Query.cs index 7792f8e2..d728cb98 100644 --- a/test/Linq2GraphQL.TestServer/Query.cs +++ b/test/Linq2GraphQL.TestServer/Query.cs @@ -1,4 +1,5 @@ -using Linq2GraphQL.TestServer.Data; +using HotChocolate; +using Linq2GraphQL.TestServer.Data; using Linq2GraphQL.TestServer.Models; namespace Linq2GraphQL.TestServer; @@ -11,6 +12,23 @@ public string Hello(string name = "World") return $"Hello, {name}!"; } + public string RaiseError(string message = "Test error") + { + throw new GraphQLException(ErrorBuilder.New() + .SetMessage(message) + .SetCode("TEST_ERROR") + .SetExtension("statusCode", 400) + .Build()); + } + + public string RaiseAuthError() + { + throw new GraphQLException(ErrorBuilder.New() + .SetMessage("Not authenticated") + .SetCode("UNAUTHENTICATED") + .Build()); + } + public Customer GetCustomerReturnNull() { return null; diff --git a/test/Linq2GraphQL.Tests/ErrorHandlingTests.cs b/test/Linq2GraphQL.Tests/ErrorHandlingTests.cs new file mode 100644 index 00000000..10b307b9 --- /dev/null +++ b/test/Linq2GraphQL.Tests/ErrorHandlingTests.cs @@ -0,0 +1,304 @@ +using System.Text.Json; +using Linq2GraphQL.Client; +using Linq2GraphQL.TestClient; +using Shouldly; + +namespace Linq2GraphQL.Tests; + +public class ErrorHandlingTests : IClassFixture +{ + private readonly SampleClient sampleClient; + + public ErrorHandlingTests(SampleClientFixture fixture) + { + sampleClient = fixture.sampleClient; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + // === GraphQueryError deserialization tests === + + [Fact] + public void GraphQueryError_Deserialize_MessageOnly() + { + var json = @"{""message"":""Something went wrong""}"; + var error = JsonSerializer.Deserialize(json, JsonOptions); + + error.ShouldNotBeNull(); + error.Message.ShouldBe("Something went wrong"); + error.ErrorCode.ShouldBe(GraphErrorCode.Unknown); + } + + [Fact] + public void GraphQueryError_Deserialize_WithExtensions() + { + var json = @"{""message"":""Not authenticated"",""extensions"":{""code"":""UNAUTHENTICATED"",""statusCode"":401}}"; + var error = JsonSerializer.Deserialize(json, JsonOptions); + + error.ShouldNotBeNull(); + error.Message.ShouldBe("Not authenticated"); + error.Extensions.ShouldNotBeNull(); + error.Extensions["code"].ShouldNotBeNull(); + error.ErrorCode.ShouldBe(GraphErrorCode.Authentication); + } + + [Fact] + public void GraphQueryError_Deserialize_WithLocations() + { + var json = @"{""message"":""Syntax Error"",""locations"":[{""line"":3,""column"":10}]}"; + var error = JsonSerializer.Deserialize(json, JsonOptions); + + error.ShouldNotBeNull(); + error.Locations.ShouldNotBeNull(); + error.Locations.Length.ShouldBe(1); + error.Locations[0].Line.ShouldBe(3); + error.Locations[0].Column.ShouldBe(10); + } + + [Fact] + public void GraphQueryError_Deserialize_WithPath() + { + var json = @"{""message"":""fail"",""path"":[""createUser"",""email""]}"; + var error = JsonSerializer.Deserialize(json, JsonOptions); + + error.ShouldNotBeNull(); + error.Path.ShouldNotBeNull(); + error.Path.Count.ShouldBe(2); + } + + [Fact] + public void GraphQueryError_Deserialize_FullError() + { + var json = @"{""message"":""Validation failed"",""locations"":[{""line"":3,""column"":10}],""path"":[""createUser"",""email""],""extensions"":{""code"":""BAD_USER_INPUT"",""statusCode"":400}}"; + var error = JsonSerializer.Deserialize(json, JsonOptions); + + error.ShouldNotBeNull(); + error.Message.ShouldBe("Validation failed"); + error.Locations.ShouldNotBeNull(); + error.Locations.Length.ShouldBe(1); + error.Path.ShouldNotBeNull(); + error.Path.Count.ShouldBe(2); + error.Extensions.ShouldNotBeNull(); + error.ErrorCode.ShouldBe(GraphErrorCode.BadRequest); + } + + // === GraphQL response parsing tests === + + [Fact] + public void GraphQlResponse_ErrorsOnly_ParsedCorrectly() + { + var json = @"{""errors"":[{""message"":""Something went wrong"",""extensions"":{""code"":""UNAUTHENTICATED""}}]}"; + var doc = JsonDocument.Parse(json); + + doc.RootElement.TryGetProperty("errors", out var errorsEl).ShouldBeTrue(); + var errors = errorsEl.Deserialize>(JsonOptions); + errors.ShouldNotBeNull(); + errors.Count.ShouldBe(1); + errors[0].Message.ShouldBe("Something went wrong"); + errors[0].ErrorCode.ShouldBe(GraphErrorCode.Authentication); + } + + [Fact] + public void GraphQlResponse_DataAndErrors_BothPresent() + { + var json = @"{""data"":{""hello"":""partial""},""errors"":[{""message"":""field x failed""}]}"; + var doc = JsonDocument.Parse(json); + + doc.RootElement.TryGetProperty("data", out var dataEl).ShouldBeTrue(); + doc.RootElement.TryGetProperty("errors", out var errorsEl).ShouldBeTrue(); + + dataEl.TryGetProperty("hello", out var helloEl).ShouldBeTrue(); + helloEl.GetString().ShouldBe("partial"); + + var errors = errorsEl.Deserialize>(JsonOptions); + errors!.Count.ShouldBe(1); + errors[0].Message.ShouldBe("field x failed"); + } + + [Fact] + public void GraphQlResponse_ResponseExtensions_Parsed() + { + var json = @"{""data"":{""test"":""hello""},""extensions"":{""trackingId"":""abc-123"",""timing"":42.5}}"; + var doc = JsonDocument.Parse(json); + + doc.RootElement.TryGetProperty("extensions", out var extEl).ShouldBeTrue(); + var extensions = extEl.Deserialize>(JsonOptions); + extensions.ShouldNotBeNull(); + extensions.ContainsKey("trackingId").ShouldBeTrue(); + } + + // === Error code classification tests === + + [Theory] + [InlineData("UNAUTHENTICATED", GraphErrorCode.Authentication)] + [InlineData("FORBIDDEN", GraphErrorCode.Forbidden)] + [InlineData("BAD_USER_INPUT", GraphErrorCode.BadRequest)] + [InlineData("INTERNAL_SERVER_ERROR", GraphErrorCode.InternalServerError)] + [InlineData("GRAPHQL_VALIDATION_FAILED", GraphErrorCode.Validation)] + [InlineData("PERSISTED_QUERY_NOT_FOUND", GraphErrorCode.PersistedQueryNotFound)] + [InlineData("RATE_LIMITED", GraphErrorCode.RateLimited)] + [InlineData("TIMEOUT", GraphErrorCode.Timeout)] + [InlineData("NOT_FOUND", GraphErrorCode.NotFound)] + [InlineData("CONFLICT", GraphErrorCode.Conflict)] + [InlineData("UNKNOWN_THING", GraphErrorCode.Unknown)] + public void GraphErrorCode_ClassifiesCodes(string code, GraphErrorCode expected) + { + var error = new GraphQueryError + { + Message = "test", + Extensions = new Dictionary { { "code", code } } + }; + error.ErrorCode.ShouldBe(expected); + } + + [Fact] + public void GraphErrorCode_NoExtensions_ReturnsUnknown() + { + var error = new GraphQueryError { Message = "test" }; + error.ErrorCode.ShouldBe(GraphErrorCode.Unknown); + } + + [Fact] + public void GraphErrorCode_NoCodeInExtensions_ReturnsUnknown() + { + var error = new GraphQueryError + { + Message = "test", + Extensions = new Dictionary { { "other", "value" } } + }; + error.ErrorCode.ShouldBe(GraphErrorCode.Unknown); + } + + // === GraphResult tests === + + [Fact] + public void GraphResult_EnsureNoErrors_ThrowsWhenHasErrors() + { + var result = new GraphResult + { + Data = null, + Errors = new List { new() { Message = "fail" } }, + Extensions = null + }; + + Assert.Throws(() => result.EnsureNoErrors()); + } + + [Fact] + public void GraphResult_EnsureNoErrors_DoesNotThrowWhenNoErrors() + { + var result = new GraphResult + { + Data = "hello", + Errors = null, + Extensions = null + }; + + result.EnsureNoErrors(); + } + + [Fact] + public void GraphResult_HasErrors_NullErrors_ReturnsFalse() + { + var result = new GraphResult { Errors = null }; + result.HasErrors.ShouldBeFalse(); + } + + [Fact] + public void GraphResult_HasErrors_EmptyErrors_ReturnsFalse() + { + var result = new GraphResult { Errors = new List() }; + result.HasErrors.ShouldBeFalse(); + } + + [Fact] + public void GraphResult_HasData_NonDefaultData_ReturnsTrue() + { + var result = new GraphResult { Data = "hello" }; + result.HasData.ShouldBeTrue(); + } + + [Fact] + public void GraphResult_HasData_NullData_ReturnsFalse() + { + var result = new GraphResult { Data = null }; + result.HasData.ShouldBeFalse(); + } + + // === Exception tests === + + [Fact] + public void GraphQueryExecutionException_PreservesQueryAndVariables() + { + var errors = new List + { + new() + { + Message = "test error", + Extensions = new Dictionary { { "code", "VALIDATION_FAILED" } } + } + }; + var vars = new Dictionary { { "id", 42 } }; + + var ex = new GraphQueryExecutionException(errors, "query { hello }", vars); + + ex.GraphQLQuery.ShouldBe("query { hello }"); + ex.GraphQLVariables["id"].ShouldBe(42); + ex.Errors.First().Message.ShouldBe("test error"); + ex.Errors.First().ErrorCode.ShouldBe(GraphErrorCode.Validation); + } + + [Fact] + public void GraphQueryExecutionException_NullQueryAndVariables() + { + var errors = new List { new() { Message = "fail" } }; + var ex = new GraphQueryExecutionException(errors, null, null); + + ex.Errors.Count().ShouldBe(1); + ex.GraphQLQuery.ShouldBeNull(); + ex.GraphQLVariables.ShouldBeNull(); + } + + [Fact] + public void GraphQueryRequestException_PreservesQueryAndVariables() + { + var vars = new Dictionary { { "name", "test" } }; + var ex = new GraphQueryRequestException("HTTP 500", "query { foo }", vars); + + ex.Message.ShouldContain("HTTP 500"); + ex.GraphQLQuery.ShouldBe("query { foo }"); + ex.GraphQLVariables["name"].ShouldBe("test"); + } + + // === Integration: ExecuteWithResultAsync success path === + + [Fact] + public async Task ExecuteWithResultAsync_SuccessfulQuery_ReturnsDataNoErrors() + { + var result = await sampleClient + .Query + .Hello("World") + .Select() + .ExecuteWithResultAsync(); + + result.HasErrors.ShouldBeFalse(); + result.HasData.ShouldBeTrue(); + result.Data.ShouldBe("Hello, World!"); + result.Errors.ShouldBeNull(); + } + + [Fact] + public async Task ExecuteAsync_SuccessfulQuery_ReturnsData() + { + var result = await sampleClient + .Query + .Hello("Test") + .Select() + .ExecuteAsync(); + + result.ShouldBe("Hello, Test!"); + } +}