Flurl

Guide to using Flurl, a fluent URL builder and HTTP client library for .NET

Flurl Repo stars 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.

InfoLink
LicenseGitHub (MIT)
DownloadsNuget
Latest VersionNuGet (4.0.2)
IssuesGitHub issues
ContributorsGitHub contributors

Key Features

  • Fluent URL Building: Construct and manipulate URLs with chainable methods
  • String Extension Methods: Start HTTP requests directly from any string URL
  • Automatic JSON Serialization: Built-in JSON handling with System.Text.Json
  • Testability: Powerful HttpTest framework—no mocking library required
  • Exception Handling: Rich FlurlHttpException with 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();
✅ Pros

  • 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 - HttpTest requires no mocking framework
  • Lightweight - Minimal dependencies, built on HttpClient
  • MIT licensed - No usage restrictions

⚠️ Considerations

  • String extension approach - May be considered unconventional
  • Memory allocations - Higher than direct HttpClient usage
  • 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:

  1. Rapid prototyping and quick API integrations
  2. Small to medium projects where developer productivity is key
  3. API exploration during development
  4. Applications with moderate API requirements benefiting from concise syntax
  5. 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

Further Reading