Skip to content

Commit 7a10862

Browse files
Cleanup
1 parent cb39608 commit 7a10862

File tree

5 files changed

+132
-48
lines changed

5 files changed

+132
-48
lines changed

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ dotnet_diagnostic.SA1514.severity = none
372372
dotnet_diagnostic.SA1400.severity = none
373373
dotnet_diagnostic.SA1114.severity = none
374374
dotnet_diagnostic.SA1118.severity = none
375+
dotnet_diagnostic.SA1649.severity = none
375376

376377

377378

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
<PrivateAssets>all</PrivateAssets>
2222
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
2323
</PackageReference>
24-
<PackageReference Include="Exhaustion" Version="1.0.0" Condition="'$(MSBuildProjectName)' != 'Exhaustion' AND '$(MSBuildProjectName)' != 'Exhaustion.Tests' AND '$(MSBuildProjectName)' != 'RestClient.Net'">
24+
<PackageReference Include="Exhaustion" Version="1.0.0" Condition="'$(MSBuildProjectName)' != 'Exhaustion' AND '$(MSBuildProjectName)' != 'Exhaustion.Tests' AND '$(MSBuildProjectName)' != 'RestClient.Net' AND '$(MSBuildProjectName)' != 'Buh'">
2525
<PrivateAssets>all</PrivateAssets>
2626
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
2727
</PackageReference>

README.md

Lines changed: 106 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
**The safest way to make REST calls in C#**
66

7-
Built from the ground up with functional programming, type safety, and modern .NET patterns. Successor to the [original RestClient.Net](https://github.com/MelbourneDeveloper/RestClient.Net).
7+
Built from the ground up with functional programming, type safety, and modern .NET patterns. Successor to the [original RestClient.Net](https://www.nuget.org/packages/RestClient.Net.Abstractions).
88

99
## What Makes It Different
1010

1111
This library is uncompromising in its approach to type safety and functional design:
1212
- **HttpClient extensions** - Works with [HttpClient lifecycle management](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines#recommended-use) via `IHttpClientFactory.CreateClient()`
1313
- **Result types** - Explicit error handling without exceptions
14-
- **Exhaustiveness checking** - Compile-time guarantees via [Exhaustion](https://github.com/MelbourneDeveloper/Exhaustion)
15-
- **Functional composition** - Delegate factories, pure functions, no OOP ceremony
14+
- **Exhaustiveness checking** - Compile-time guarantees via [Exhaustion](https://www.nuget.org/packages/Exhaustion)
15+
- **Functional composition** - Pure functions with no interfaces
1616

1717
## Features
1818

@@ -28,53 +28,130 @@ The design focuses on [discriminated unions](https://github.com/dotnet/csharplan
2828

2929
## Installation
3030

31+
Install the core package:
3132
```bash
3233
dotnet add package RestClient.Net
3334
```
3435

36+
RestClient.Net installs the Exhaustion analyzer. Is is a Roslyn analyzer that provides compile-time exhaustiveness checking for pattern matching. Without it, you lose one of RestClient.Net's core safety guarantees.
37+
3538
## Usage
3639

3740
### Basic GET Request
3841

39-
The simplest way to make a GET request to JSONPlaceholder:
42+
A complete example making a GET request to JSONPlaceholder:
4043

4144
```csharp
42-
using System.Net.Http.Json;
45+
using System.Text.Json;
4346
using RestClient.Net;
47+
using Urls;
48+
49+
// Define models
50+
internal sealed record Post(int UserId, int Id, string Title, string Body);
51+
internal sealed record ErrorResponse(string Message);
52+
53+
// Create HttpClient (use IHttpClientFactory in production)
54+
using var httpClient = new HttpClient();
55+
56+
// Make the GET call
57+
var result = await httpClient
58+
.GetAsync(
59+
url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(),
60+
deserializeSuccess: DeserializePost,
61+
deserializeError: DeserializeError
62+
)
63+
.ConfigureAwait(false);
64+
65+
// Pattern match on the result - MUST handle all cases
66+
var output = result switch
67+
{
68+
OkPost(var post) =>
69+
$"Success: {post.Title} by user {post.UserId}",
70+
ErrorPost(ResponseErrorPost(var errorBody, var statusCode, _)) =>
71+
$"Error {statusCode}: {errorBody.Message}",
72+
ErrorPost(ExceptionErrorPost(var exception)) =>
73+
$"Exception: {exception.Message}",
74+
};
75+
76+
Console.WriteLine(output);
77+
78+
async Task<Post?> DeserializePost(HttpResponseMessage response, CancellationToken ct) =>
79+
await JsonSerializer
80+
.DeserializeAsync<Post>(
81+
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
82+
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
83+
cancellationToken: ct
84+
)
85+
.ConfigureAwait(false);
86+
87+
async Task<ErrorResponse?> DeserializeError(HttpResponseMessage response, CancellationToken ct) =>
88+
await JsonSerializer
89+
.DeserializeAsync<ErrorResponse>(
90+
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
91+
new JsonSerializerOptions { PropertyNameCaseInsensitive = true },
92+
cancellationToken: ct
93+
)
94+
.ConfigureAwait(false);
95+
```
4496

45-
// Define a simple User model
46-
public record User(int Id, string Name, string Email);
97+
### Result Type and Type Aliases
4798

48-
// Get HttpClient from IHttpClientFactory
49-
var httpClient = httpClientFactory.CreateClient();
99+
C# doesn't officially support discriminated unions, but you can achieve closed type hierarchies with the [sealed modifier](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/sealed). The [Outcome package](https://www.nuget.org/packages/Outcome/) gives you a set of Result types designed for exhaustiveness checks. Until C# gains full discriminated union support, you need to add type aliases like this:
50100

51-
// Make a direct GET request
52-
var result = await httpClient.GetAsync<User, string>(
53-
url: "https://jsonplaceholder.typicode.com/users/1".ToAbsoluteUrl(),
54-
deserializeSuccess: (response, ct) => response.Content.ReadFromJsonAsync<User>(ct),
55-
deserializeError: (response, ct) => response.Content.ReadAsStringAsync(ct)
56-
);
101+
```cs
102+
// Type aliases for concise pattern matching (usually in GlobalUsings.cs)
103+
global using OkPost = Outcome.Result<Post, Outcome.HttpError<ErrorResponse>>.Ok<Post, Outcome.HttpError<ErrorResponse>>;
104+
global using ErrorPost = Outcome.Result<Post, Outcome.HttpError<ErrorResponse>>.Error<Post, Outcome.HttpError<ErrorResponse>>;
105+
global using ResponseErrorPost = Outcome.HttpError<ErrorResponse>.ErrorResponseError;
106+
global using ExceptionErrorPost = Outcome.HttpError<ErrorResponse>.ExceptionError;
107+
```
108+
109+
If you use the OpenAPI generator, it will generate these type aliases for you automatically.
110+
111+
## Exhaustiveness Checking with Exhaustion
112+
113+
**Exhaustion is integral to RestClient.Net's safety guarantees.** It's a Roslyn analyzer that ensures you handle every possible case when pattern matching on Result types.
57114

58-
switch (result)
115+
### What Happens Without Exhaustion
116+
117+
If you remove a switch arm and don't have Exhaustion installed, the code compiles but may crash at runtime:
118+
119+
```csharp
120+
// DANGEROUS - compiles but may throw at runtime
121+
var output = result switch
59122
{
60-
case OkUser(var user):
61-
Console.WriteLine($"Success: {user.Name}");
62-
break;
63-
case ErrorUser(var error):
64-
Console.WriteLine($"Failed: {error.StatusCode} - {error.Body}");
65-
break;
66-
}
123+
OkPost(var post) => $"Success: {post.Title}",
124+
ErrorPost(ResponseErrorPost(var errorBody, var statusCode, _)) =>
125+
$"Error {statusCode}: {errorBody.Message}",
126+
// Missing ExceptionErrorPost case - will throw at runtime if an exception occurs!
127+
};
67128
```
68129

69-
### Result Type and Type Aliases
130+
### What Happens With Exhaustion
70131

71-
C# doesn't officially support discriminated unions, but you can achieve closed type hierarchies with the [sealed modifer](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/sealed). The [Outcome package](https://www.nuget.org/packages/Outcome/) gives you a set of Result types designed for exhaustiveness checks. Until C# gains full discriminated union support, you need to add type aliases like this. If you use the generator, it will generate the type aliases for you.
132+
With Exhaustion installed, the compiler **catches this at build time**:
72133

73-
```cs
74-
// Type aliases for concise pattern matching
75-
using OkUser = Result<User, HttpError<string>>.Ok<User, HttpError<string>>;
76-
using ErrorUser = Result<User, HttpError<string>>.Error<User, HttpError<string>>;
77134
```
135+
error EXHAUSTION001: Switch on Result is not exhaustive;
136+
Matched: Ok<Post, HttpError<ErrorResponse>>, Error<Post, HttpError<ErrorResponse>> with ErrorResponseError<ErrorResponse>
137+
Missing: Error<Post, HttpError<ErrorResponse>> with ExceptionError<ErrorResponse>
138+
```
139+
140+
Your build fails until you handle all cases. This is the difference between **runtime crashes** and **compile-time safety**.
141+
142+
### Installing Exhaustion Without RestClient.Net
143+
144+
Add it to your project:
145+
```bash
146+
dotnet add package Exhaustion
147+
```
148+
149+
Exhaustion works by analyzing sealed type hierarchies in switch expressions and statements. When you match on a `Result<TSuccess, HttpError<TError>>`, it knows there are exactly three possible cases:
150+
- `Ok<TSuccess, HttpError<TError>>` - Success case
151+
- `Error<TSuccess, HttpError<TError>>` with `ErrorResponseError<TError>` - HTTP error response
152+
- `Error<TSuccess, HttpError<TError>>` with `ExceptionError<TError>` - Exception during request
153+
154+
If you don't handle all three, your code won't compile.
78155

79156
### OpenAPI Code Generation
80157

RestClient.Net/HttpClientExtensions.cs

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,25 +68,17 @@ public static async Task<Result<TSuccess, HttpError<TError>>> SendAsync<TSuccess
6868
.SendAsync(requestMessage, cancellationToken)
6969
.ConfigureAwait(false);
7070

71-
if (response.IsSuccessStatusCode)
72-
{
73-
var successResult = await deserializeSuccess(response, cancellationToken)
74-
.ConfigureAwait(false);
75-
return new Result<TSuccess, HttpError<TError>>.Ok<TSuccess, HttpError<TError>>(
76-
successResult
77-
);
78-
}
79-
80-
var errorBody = await deserializeError(response, cancellationToken)
81-
.ConfigureAwait(false);
82-
83-
return Result<TSuccess, HttpError<TError>>.Failure(
84-
HttpError<TError>.FromErrorResponse(
85-
errorBody,
86-
response.StatusCode,
87-
response.Headers
71+
return response.IsSuccessStatusCode
72+
? new Result<TSuccess, HttpError<TError>>.Ok<TSuccess, HttpError<TError>>(
73+
await deserializeSuccess(response, cancellationToken).ConfigureAwait(false)
8874
)
89-
);
75+
: Result<TSuccess, HttpError<TError>>.Failure(
76+
HttpError<TError>.FromErrorResponse(
77+
await deserializeError(response, cancellationToken).ConfigureAwait(false),
78+
response.StatusCode,
79+
response.Headers
80+
)
81+
);
9082
}
9183
catch (Exception ex)
9284
{

RestClient.sln

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RestClient.Net.OpenApiGener
3737
EndProject
3838
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "RestClient.Net.FsTest", "RestClient.Net.FsTest\RestClient.Net.FsTest.fsproj", "{E9EEE1D7-2A49-4665-8CBA-B0DC22BEB254}"
3939
EndProject
40+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "buh", "buh\buh.csproj", "{4FB10353-67B0-4EE8-A374-F16644CC7A84}"
41+
EndProject
4042
Global
4143
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4244
Debug|Any CPU = Debug|Any CPU
@@ -239,6 +241,18 @@ Global
239241
{E9EEE1D7-2A49-4665-8CBA-B0DC22BEB254}.Release|x64.Build.0 = Release|Any CPU
240242
{E9EEE1D7-2A49-4665-8CBA-B0DC22BEB254}.Release|x86.ActiveCfg = Release|Any CPU
241243
{E9EEE1D7-2A49-4665-8CBA-B0DC22BEB254}.Release|x86.Build.0 = Release|Any CPU
244+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
245+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Debug|Any CPU.Build.0 = Debug|Any CPU
246+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Debug|x64.ActiveCfg = Debug|Any CPU
247+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Debug|x64.Build.0 = Debug|Any CPU
248+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Debug|x86.ActiveCfg = Debug|Any CPU
249+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Debug|x86.Build.0 = Debug|Any CPU
250+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Release|Any CPU.ActiveCfg = Release|Any CPU
251+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Release|Any CPU.Build.0 = Release|Any CPU
252+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Release|x64.ActiveCfg = Release|Any CPU
253+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Release|x64.Build.0 = Release|Any CPU
254+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Release|x86.ActiveCfg = Release|Any CPU
255+
{4FB10353-67B0-4EE8-A374-F16644CC7A84}.Release|x86.Build.0 = Release|Any CPU
242256
EndGlobalSection
243257
GlobalSection(SolutionProperties) = preSolution
244258
HideSolutionNode = FALSE

0 commit comments

Comments
 (0)