Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ using System;
using SumUp;

using var client = new SumUpClient();
var response = await client.Checkouts.List();
var response = await client.Checkouts.ListAsync();

foreach (var checkout in response.Data ?? Array.Empty<CheckoutSuccess>())
{
Expand All @@ -56,10 +56,7 @@ using SumUp;

using var client = new SumUpClient();

// Merchant profile contains the merchant code required when creating checkouts
var merchantResponse = await client.Merchant.GetAsync();
var merchantCode = merchantResponse.Data?.MerchantProfile?.MerchantCode
?? throw new InvalidOperationException("Merchant code not returned.");
var merchantCode = "your-merchant-code";

var checkoutReference = $"checkout-{Guid.NewGuid():N}";

Expand Down Expand Up @@ -104,6 +101,28 @@ var readerCheckout = await client.Readers.CreateCheckoutAsync(
Console.WriteLine($"Reader checkout created: {readerCheckout.Data?.Data?.ClientTransactionId}");
```

### Handling errors

Catch `ApiException` to inspect an unsuccessful API response. Problem responses
provide typed details:

```csharp
using SumUp.Http;

try
{
await client.Checkouts.GetAsync("your-checkout-id");
}
catch (ApiException error)
{
if (error.Problem is { } problem)
Console.WriteLine($"{problem.Type}: {problem.Detail}");
}
```

`StatusCode`, `ResponseBody`, and `Headers` provide the HTTP status, original
response text, and headers.

### Handling events

Create a handler with your endpoint's signing secret and a fallback for unregistered event types:
Expand Down Expand Up @@ -136,7 +155,7 @@ See [the ASP.NET Core example](examples/Events) for a complete HTTP endpoint.
## Examples

- `examples/Basic` – lists recent checkouts to sanity check your API token.
- `examples/CardReaderCheckout` – mirrors the `../sumup-rs/examples/card_reader_checkout.rs` sample by listing the merchant’s paired readers and creating a €10 checkout on the first available device.
- `examples/CardReaderCheckout` – lists the merchant’s paired readers and creates a €10 checkout on the first available device.
- `examples/OAuth2` – starts a local OAuth2 Authorization Code flow with PKCE, exchanges the callback code for an access token, and fetches merchant information using the returned `merchant_code`.
- `examples/Events` – runs a minimal ASP.NET Core endpoint with signature verification, typed event callbacks, and resource fetching.

Expand Down
16 changes: 8 additions & 8 deletions codegen/internal/generator/templates/client.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public sealed partial class {{ .ClientName }}Client
case {{ .StatusCodeLiteral }}:
{
var errorForStatus{{ .StatusCodeLiteral }} = _client.TryDeserialize<{{ .ErrorType }}>(responseBody);
throw new ApiException<{{ .ErrorType }}>(response.StatusCode, errorForStatus{{ .StatusCodeLiteral }}, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException<{{ .ErrorType }}>(response.StatusCode, errorForStatus{{ .StatusCodeLiteral }}, responseBody, response.RequestMessage?.RequestUri, response.Headers);
}
{{- end }}
{{- end }}
Expand All @@ -110,14 +110,14 @@ public sealed partial class {{ .ClientName }}Client
{{- end }}
{{- if $defaultError }}
var defaultError = _client.TryDeserialize<{{ $defaultError }}>(responseBody);
throw new ApiException<{{ $defaultError }}>(response.StatusCode, defaultError, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException<{{ $defaultError }}>(response.StatusCode, defaultError, responseBody, response.RequestMessage?.RequestUri, response.Headers);
{{- else }}
var fallbackError = _client.TryDeserialize<ApiError>(responseBody);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri, response.Headers);
{{- end }}
{{- else }}
var fallbackError = _client.TryDeserialize<ApiError>(responseBody);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri, response.Headers);
{{- end }}
}

Expand Down Expand Up @@ -209,7 +209,7 @@ public sealed partial class {{ .ClientName }}Client
case {{ .StatusCodeLiteral }}:
{
var errorForStatus{{ .StatusCodeLiteral }} = _client.TryDeserialize<{{ .ErrorType }}>(responseBody);
throw new ApiException<{{ .ErrorType }}>(response.StatusCode, errorForStatus{{ .StatusCodeLiteral }}, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException<{{ .ErrorType }}>(response.StatusCode, errorForStatus{{ .StatusCodeLiteral }}, responseBody, response.RequestMessage?.RequestUri, response.Headers);
}
{{- end }}
{{- end }}
Expand All @@ -222,14 +222,14 @@ public sealed partial class {{ .ClientName }}Client
{{- end }}
{{- if $defaultErrorAsync }}
var defaultError = _client.TryDeserialize<{{ $defaultErrorAsync }}>(responseBody);
throw new ApiException<{{ $defaultErrorAsync }}>(response.StatusCode, defaultError, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException<{{ $defaultErrorAsync }}>(response.StatusCode, defaultError, responseBody, response.RequestMessage?.RequestUri, response.Headers);
{{- else }}
var fallbackError = _client.TryDeserialize<ApiError>(responseBody);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri, response.Headers);
{{- end }}
{{- else }}
var fallbackError = _client.TryDeserialize<ApiError>(responseBody);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri);
throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri, response.Headers);
{{- end }}
}

Expand Down
54 changes: 54 additions & 0 deletions src/SumUp.Tests/ApiExceptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Net;
using System.Net.Http;
using SumUp.Http;
using Xunit;

namespace SumUp.Tests;

public class ApiExceptionTests
{
[Fact]
public void BaseCatchExposesTheTypedProblemAndHeaders()
{
var problem = new Problem { Type = "about:blank", Detail = "Invalid amount" };
using var response = new HttpResponseMessage(HttpStatusCode.BadRequest);
response.Headers.Add("Retry-After", "10");
ApiException error = new ApiException<Problem>(response.StatusCode, problem, "raw", null, response.Headers);
Assert.Same(problem, error.Problem);
Assert.Equal("Invalid amount", error.Problem!.Detail);
Assert.Same(response.Headers, error.Headers);
Assert.Equal("raw", error.ResponseBody);
}

[Theory]
[InlineData("Invalid amount", "Bad request", "Invalid amount")]
[InlineData(null, "Bad request", "Bad request")]
public void ProblemDetailsAppearInMessages(string? detail, string title, string expected)
{
var problem = new Problem { Type = "about:blank", Detail = detail, Title = title };
var payload = System.Text.Json.JsonSerializer.Serialize(problem);
ApiException typed = new ApiException<Problem>(HttpStatusCode.BadRequest, problem, payload, null);
var fallback = new ApiException(HttpStatusCode.BadRequest, null, payload, null);
Assert.Equal($"SumUp API request failed with status code 400. {expected}", typed.Message);
Assert.Equal(typed.Message, fallback.Message);
}

[Fact]
public void IncompleteProblemIsNotExposedByTheConvenienceProperty()
{
var problem = new Problem { Detail = "Missing type" };
ApiException error = new ApiException<Problem>(HttpStatusCode.BadRequest, problem, "{}", null);
Assert.Null(error.Problem);
}

[Theory]
[InlineData("{invalid")]
[InlineData("Bad gateway")]
[InlineData("{\"message\":\"Unknown\"}")]
public void NonProblemBodyRemainsAvailable(string body)
{
var error = new ApiException(HttpStatusCode.BadGateway, null, body, null);
Assert.Null(error.Problem);
Assert.Equal(body, error.ResponseBody);
}
}
Loading
Loading