Flurl
7 minute read
is an elegant HTTP client library that combines the simplicity of string-based URL manipulation with the power of a full-featured HTTP client. Its name is a portmanteau of “Fluent” and “URL,” reflecting its core design philosophy.
Flurl consists of two main NuGet packages:
- Flurl : Core URL builder with fluent syntax for constructing and manipulating URLs
- Flurl.Http
: HTTP client extensions built on top of
HttpClient
Unlike traditional REST clients that require separate client objects, Flurl extends the string type to allow any URL to be the starting point for an HTTP request. This enables an extremely concise and chainable API.
| Info | Link |
|---|---|
| License | |
| Downloads | |
| Latest Version | |
| Issues | |
| Contributors |
Key Features
- Fluent URL Building: Construct and manipulate URLs with chainable methods
- String Extension Methods: Start HTTP requests directly from any
stringURL - Automatic JSON Serialization: Built-in JSON handling with
System.Text.Json - Testability: Powerful
HttpTestframework—no mocking library required - Exception Handling: Rich
FlurlHttpExceptionwith response access - Authentication: Built-in OAuth, Basic Auth, and custom header support
- Lightweight: Minimal dependencies, built on native
HttpClient
Quick Start
Install the package:
dotnet add package Flurl.Http
Basic HTTP operations with explicit types:
using Flurl.Http;
string baseUrl = "https://api.example.com";
// GET - Retrieve a single resource
TaskDto? task = await $"{baseUrl}/tasks/123".GetJsonAsync<TaskDto?>();
// GET - Retrieve a collection
List<TaskDto> tasks = await $"{baseUrl}/tasks".GetJsonAsync<List<TaskDto>>();
// POST - Create a new resource
CreateTaskRequest newTask = new() { Title = "Buy flowers", Priority = "high" };
TaskDto createdTask = await $"{baseUrl}/tasks"
.PostJsonAsync(newTask)
.ReceiveJson<TaskDto>();
// PUT - Update an existing resource
UpdateTaskRequest updateRequest = new() { Title = "Buy roses", Completed = true };
TaskDto updatedTask = await $"{baseUrl}/tasks/123"
.PutJsonAsync(updateRequest)
.ReceiveJson<TaskDto>();
// DELETE - Remove a resource
await $"{baseUrl}/tasks/123".DeleteAsync();
DTO definitions with explicit types:
/// <summary>
/// Represents a task returned from the API.
/// </summary>
public sealed record TaskDto
{
public required int Id { get; init; }
public required string Title { get; init; }
public bool Completed { get; init; }
public string? Description { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Request model for creating a new task.
/// </summary>
public sealed record CreateTaskRequest
{
public required string Title { get; init; }
public string? Description { get; init; }
public string Priority { get; init; } = "normal";
}
/// <summary>
/// Request model for updating an existing task.
/// </summary>
public sealed record UpdateTaskRequest
{
public string? Title { get; init; }
public string? Description { get; init; }
public bool? Completed { get; init; }
}
URL Building
Flurl’s URL building capabilities are powerful and flexible:
using Flurl;
using Flurl.Http;
// Query parameters - individual
List<TaskDto> filtered = await "https://api.example.com/tasks"
.SetQueryParam("status", "pending")
.SetQueryParam("priority", "high")
.SetQueryParam("limit", 50)
.GetJsonAsync<List<TaskDto>>();
// Query parameters - anonymous object
SearchResult result = await "https://api.example.com/search"
.SetQueryParams(new
{
q = "dotnet rest",
category = "tutorials",
maxResults = 25,
includeArchived = false
})
.GetJsonAsync<SearchResult>();
// Path segments
UserDto user = await "https://api.example.com"
.AppendPathSegment("users")
.AppendPathSegment(userId)
.AppendPathSegment("profile")
.GetJsonAsync<UserDto>();
// Combined building with Flurl.Url
Url apiUrl = new Url("https://api.example.com")
.AppendPathSegment("api")
.AppendPathSegment("v2")
.SetQueryParam("format", "json");
string fullUrl = apiUrl.ToString();
// Result: "https://api.example.com/api/v2?format=json"
Headers and Authentication
using Flurl.Http;
// Custom headers
List<TaskDto> tasks = await "https://api.example.com/tasks"
.WithHeader("User-Agent", "TaskClient/1.0")
.WithHeader("Accept-Language", "en-US")
.WithHeader("X-Request-Id", Guid.NewGuid().ToString())
.GetJsonAsync<List<TaskDto>>();
// Basic authentication
SecureResource resource = await "https://api.example.com/secure"
.WithBasicAuth("username", "password")
.GetJsonAsync<SecureResource>();
// OAuth Bearer token
UserProfile profile = await "https://api.example.com/me"
.WithOAuthBearerToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
.GetJsonAsync<UserProfile>();
// API key in header
List<Product> products = await "https://api.example.com/products"
.WithHeader("X-API-Key", "your-api-key")
.GetJsonAsync<List<Product>>();
// Multiple headers via object
TaskDto task = await "https://api.example.com/tasks/123"
.WithHeaders(new
{
Authorization = "Bearer token",
Accept = "application/json",
X_Custom_Header = "value" // underscores become hyphens
})
.GetJsonAsync<TaskDto>();
Error Handling
using Flurl.Http;
try
{
TaskDto task = await "https://api.example.com/tasks/999"
.GetJsonAsync<TaskDto>();
}
catch (FlurlHttpException ex)
{
int? statusCode = ex.StatusCode;
if (statusCode == 404)
{
Console.WriteLine("Task not found");
}
else if (statusCode == 401)
{
Console.WriteLine("Authentication required");
}
else if (statusCode == 403)
{
Console.WriteLine("Access denied");
}
else
{
// Read error response body
ErrorResponse? errorBody = await ex.GetResponseJsonAsync<ErrorResponse>();
Console.WriteLine($"HTTP {statusCode}: {errorBody?.Message}");
}
// Access to raw response content
string? rawContent = await ex.GetResponseStringAsync();
Console.WriteLine($"Raw response: {rawContent}");
}
catch (FlurlHttpTimeoutException ex)
{
Console.WriteLine($"Request timed out: {ex.Message}");
}
/// <summary>
/// Standard error response from the API.
/// </summary>
public sealed record ErrorResponse
{
public required string Message { get; init; }
public string? ErrorCode { get; init; }
public Dictionary<string, string[]>? ValidationErrors { get; init; }
}
Testing with HttpTest
Flurl’s built-in HttpTest enables testing without external mocking libraries:
using Flurl.Http.Testing;
using Xunit;
public sealed class TaskServiceTests
{
[Fact]
public async Task GetAllTasks_ReturnsTaskList()
{
// Arrange - Set up fake response
using HttpTest httpTest = new();
httpTest.RespondWithJson(new List<TaskDto>
{
new() { Id = 1, Title = "Task 1", Completed = false },
new() { Id = 2, Title = "Task 2", Completed = true }
});
TasksApiClient client = new("https://api.example.com");
// Act
List<TaskDto> tasks = await client.GetAllTasksAsync();
// Assert
Assert.Equal(2, tasks.Count);
httpTest.ShouldHaveCalled("https://api.example.com/tasks")
.WithVerb(HttpMethod.Get)
.Times(1);
}
[Fact]
public async Task CreateTask_SendsCorrectPayload()
{
using HttpTest httpTest = new();
httpTest.RespondWithJson(new TaskDto
{
Id = 42,
Title = "New Task",
Completed = false
});
TasksApiClient client = new("https://api.example.com");
CreateTaskRequest request = new() { Title = "New Task", Priority = "high" };
// Act
TaskDto created = await client.CreateTaskAsync(request);
// Assert
Assert.Equal(42, created.Id);
httpTest.ShouldHaveCalled("https://api.example.com/tasks")
.WithVerb(HttpMethod.Post)
.WithContentType("application/json")
.WithRequestBody("*\"title\":\"New Task\"*");
}
[Fact]
public async Task GetTask_NotFound_ThrowsFlurlHttpException()
{
using HttpTest httpTest = new();
httpTest.RespondWith(status: 404);
TasksApiClient client = new("https://api.example.com");
// Act & Assert
FlurlHttpException exception = await Assert.ThrowsAsync<FlurlHttpException>(
() => client.GetTaskByIdAsync(999));
Assert.Equal(404, exception.StatusCode);
}
}
Typed API Client Pattern
For production applications, encapsulate Flurl usage in a structured API client:
using Flurl;
using Flurl.Http;
using Flurl.Http.Configuration;
/// <summary>
/// Strongly-typed API client for the Tasks API using Flurl.
/// </summary>
public sealed class TasksApiClient : IDisposable
{
private readonly IFlurlClient _client;
/// <summary>
/// Initializes a new instance of the <see cref="TasksApiClient"/> class.
/// </summary>
/// <param name="baseUrl">The base URL of the API.</param>
public TasksApiClient(string baseUrl)
{
_client = new FlurlClient(baseUrl)
.WithTimeout(TimeSpan.FromSeconds(30))
.WithHeader("User-Agent", "TasksApiClient/1.0");
}
/// <summary>
/// Retrieves a task by its unique identifier.
/// </summary>
/// <param name="id">The task identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The task if found; otherwise null.</returns>
public async Task<TaskDto?> GetTaskByIdAsync(
int id,
CancellationToken cancellationToken = default)
{
try
{
TaskDto task = await _client.Request("tasks", id)
.GetJsonAsync<TaskDto>(cancellationToken: cancellationToken);
return task;
}
catch (FlurlHttpException ex) when (ex.StatusCode == 404)
{
return null;
}
}
/// <summary>
/// Retrieves all tasks, optionally filtered by completion status.
/// </summary>
/// <param name="completed">Filter by completion status.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of tasks.</returns>
public async Task<List<TaskDto>> GetAllTasksAsync(
bool? completed = null,
CancellationToken cancellationToken = default)
{
IFlurlRequest request = _client.Request("tasks");
if (completed.HasValue)
{
request = request.SetQueryParam("completed", completed.Value);
}
List<TaskDto> tasks = await request
.GetJsonAsync<List<TaskDto>>(cancellationToken: cancellationToken);
return tasks;
}
/// <summary>
/// Creates a new task.
/// </summary>
/// <param name="request">The task creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created task.</returns>
public async Task<TaskDto> CreateTaskAsync(
CreateTaskRequest request,
CancellationToken cancellationToken = default)
{
TaskDto createdTask = await _client.Request("tasks")
.PostJsonAsync(request, cancellationToken: cancellationToken)
.ReceiveJson<TaskDto>();
return createdTask;
}
/// <summary>
/// Updates an existing task.
/// </summary>
/// <param name="id">The task identifier.</param>
/// <param name="request">The update request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated task.</returns>
public async Task<TaskDto> UpdateTaskAsync(
int id,
UpdateTaskRequest request,
CancellationToken cancellationToken = default)
{
TaskDto updatedTask = await _client.Request("tasks", id)
.PutJsonAsync(request, cancellationToken: cancellationToken)
.ReceiveJson<TaskDto>();
return updatedTask;
}
/// <summary>
/// Deletes a task by its identifier.
/// </summary>
/// <param name="id">The task identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task DeleteTaskAsync(
int id,
CancellationToken cancellationToken = default)
{
await _client.Request("tasks", id)
.DeleteAsync(cancellationToken: cancellationToken);
}
/// <inheritdoc />
public void Dispose()
{
_client.Dispose();
}
}
Dependency Injection Registration
using Flurl.Http;
using Flurl.Http.Configuration;
using Microsoft.Extensions.DependencyInjection;
IServiceCollection services = new ServiceCollection();
// Register TasksApiClient
services.AddSingleton<TasksApiClient>(sp =>
{
IConfiguration configuration = sp.GetRequiredService<IConfiguration>();
string baseUrl = configuration["TasksApi:BaseUrl"]
?? throw new InvalidOperationException("TasksApi:BaseUrl not configured");
return new TasksApiClient(baseUrl);
});
// Usage
IServiceProvider provider = services.BuildServiceProvider();
TasksApiClient client = provider.GetRequiredService<TasksApiClient>();
List<TaskDto> tasks = await client.GetAllTasksAsync();
- Extremely intuitive - Fluent syntax is easy to read and write
- Minimal code - Simple operations in one line
- Powerful URL building - Chainable methods for complex URLs
- Built-in testing -
HttpTestrequires no mocking framework - Lightweight - Minimal dependencies, built on
HttpClient - MIT licensed - No usage restrictions
- String extension approach - May be considered unconventional
- Memory allocations - Higher than direct
HttpClientusage - No compile-time validation - API contracts not verified at build
- Encourages scattered calls - Without structure, HTTP calls spread through codebase
- Less suitable for extremely complex API scenarios
When to Choose Flurl
Flurl is particularly well-suited for:
- Rapid prototyping and quick API integrations
- Small to medium projects where developer productivity is key
- API exploration during development
- Applications with moderate API requirements benefiting from concise syntax
- Projects where testability of HTTP interactions is important
For large enterprise applications or performance-critical systems, consider whether the convenience outweighs potential performance trade-offs.
Full Sample
See the full sample on GitHub: https://github.com/BenjaminAbt/dotnet.rest-samples