Skip to main content

πŸ“ Lesson 5.1: Calling Web APIs with HttpClient

This is where it all comes together. You'll fetch live data from the internet with HttpClient β€” using the async you learned in 4.3 and the JSON from 4.2 β€” to turn a web response into real C# objects.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a web API and an HTTP request are
  • Send async GET requests with HttpClient
  • Deserialize a JSON response straight into objects with GetFromJsonAsync
  • Check status codes and handle network errors
  • Reuse a single HttpClient correctly

Estimated Time: 75 minutes

Project: Fetch and display data from a public web API.

In This Lesson

What Is a Web API?

A web API is a service on the internet that programs (not people) can call to get or send data. Your code sends an HTTP request to a URL; the server sends back an HTTP response, very often as JSON.

πŸ“– Definitions

HTTP: the protocol browsers and apps use to talk to servers. A request has a method β€” GET (fetch data), POST (send data), and others.

Web API / REST: a server exposing data at URLs (called endpoints), usually exchanging JSON.

graph LR A["Your C# app
HttpClient"] -->|"GET request (URL)"| B["Web server
(the API)"] B -->|"HTTP response (JSON)"| A A --> C["Deserialize JSON
into objects"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

In .NET, the class for making these calls is HttpClient (in System.Net.Http). Every method is asynchronous β€” network calls are exactly the slow, waiting work that async/await was made for.

Your First Request

The simplest call fetches a URL's content as a string. Since it's async, we await it (Lesson 4.3):

using System.Net.Http;

HttpClient client = new HttpClient();

string json = await client.GetStringAsync("https://api.example.com/products");
Console.WriteLine(json);   // the raw JSON text the server returned

That gives you the response body as text. You could then hand it to JsonSerializer.Deserialize from Lesson 4.2 β€” but there's a shortcut that does both steps at once, coming next.

πŸ’‘ A public API to practice with

Free "fake" APIs like https://jsonplaceholder.typicode.com return sample JSON for learning β€” for example /todos/1 returns a single to-do item. We'll use it in the exercise so you can run real requests safely.

JSON Straight into Objects

The method GetFromJsonAsync<T> (from System.Net.Http.Json) fetches a URL and deserializes the JSON response into your type in one call β€” combining Lessons 4.2 and 4.3 beautifully:

using System.Net.Http.Json;

record Todo(int Id, int UserId, string Title, bool Completed);

HttpClient client = new HttpClient();

Todo? todo = await client.GetFromJsonAsync<Todo>(
    "https://jsonplaceholder.typicode.com/todos/1");

Console.WriteLine($"{todo?.Id}: {todo?.Title} (done: {todo?.Completed})");

Fetching a whole list is just as easy β€” deserialize into a List<T>:

List<Todo>? todos = await client.GetFromJsonAsync<List<Todo>>(
    "https://jsonplaceholder.typicode.com/todos");

Console.WriteLine($"Fetched {todos?.Count} todos.");

// Now use LINQ (Lesson 2.3) on live data!
var completed = todos?.Where(t => t.Completed).Count();
Console.WriteLine($"{completed} are completed.");

βœ… Everything you've learned, working together

Look at that last example: an async network call (4.3) returns JSON deserialized (4.2) into records (3.1) in a generic List<T> (1.2), which you then query with LINQ (2.3). This is real, modern C#.

πŸ’‘ Property name matching

The JSON here uses camelCase (userId), and your record uses PascalCase (UserId). System.Text.Json matches names case-insensitively by default, so it just works β€” but this is why the naming options from Lesson 4.2 matter when APIs are involved.

Status Codes & Errors

Every HTTP response carries a status code that says how it went. You'll want to recognize the common ranges:

RangeMeaningExamples
2xxSuccess200 OK, 201 Created
4xxClient error (your request was wrong)404 Not Found, 401 Unauthorized
5xxServer error500 Internal Server Error

For finer control, use GetAsync, which returns a response object you can inspect before reading the body:

HttpResponseMessage response = await client.GetAsync(url);

if (response.IsSuccessStatusCode)   // true for any 2xx
{
    string body = await response.Content.ReadAsStringAsync();
    Console.WriteLine(body);
}
else
{
    Console.WriteLine($"Request failed: {(int)response.StatusCode} {response.StatusCode}");
}

Two kinds of things can go wrong, and robust code handles both (using Lesson 1.1's exception skills):

try
{
    Todo? todo = await client.GetFromJsonAsync<Todo>(url);
    Console.WriteLine(todo?.Title);
}
catch (HttpRequestException ex)     // network failure, DNS, non-success status
{
    Console.WriteLine($"Network error: {ex.Message}");
}
catch (TaskCanceledException)       // timeout
{
    Console.WriteLine("The request timed out.");
}

⚠️ The network is never guaranteed

Unlike local method calls, a web request can fail for reasons outside your control β€” no connection, a down server, a timeout. Always wrap API calls in try/catch (HttpRequestException). (Note: GetFromJsonAsync and EnsureSuccessStatusCode() throw HttpRequestException automatically on non-success codes.)

Reusing HttpClient

Here's a crucial real-world detail. Although HttpClient is IDisposable, you should not create a new one for every request. Doing so in a loop can exhaust the operating system's network connections (a well-known bug called socket exhaustion).

⚠️ Don't do this

// ❌ A new client per request β€” can exhaust sockets
foreach (var url in urls)
{
    using var client = new HttpClient();   // BAD in a loop
    var data = await client.GetStringAsync(url);
}

βœ… Do this instead

Create one HttpClient and reuse it for many requests. It's designed to be shared and is safe for concurrent use:

// βœ… One shared client, reused
HttpClient client = new HttpClient();

foreach (var url in urls)
{
    var data = await client.GetStringAsync(url);
}

In larger apps, the recommended approach is IHttpClientFactory (used with dependency injection), which manages client lifetimes for you. For our console programs, a single shared instance is exactly right.

πŸ’‘ Setting a base address and headers

You can configure a client once and reuse it β€” a base URL, a default timeout, headers like an API key:

HttpClient client = new HttpClient
{
    BaseAddress = new Uri("https://jsonplaceholder.typicode.com/"),
    Timeout = TimeSpan.FromSeconds(10)
};

// Then use relative paths:
var todo = await client.GetFromJsonAsync<Todo>("todos/1");

Exercise & Quiz

πŸ‹οΈ Exercise: A Live To-Do Fetcher

Objective: Fetch real JSON data from a public API and process it with LINQ.

Instructions:

  1. Create a new project called ApiClient.
  2. Define record Todo(int Id, int UserId, string Title, bool Completed);.
  3. Create one shared HttpClient.
  4. Use GetFromJsonAsync<List<Todo>> to fetch https://jsonplaceholder.typicode.com/todos.
  5. Print how many todos there are, and β€” using LINQ β€” how many are completed and the title of the first incomplete one.
  6. Wrap everything in try/catch (HttpRequestException).

Starter Code:

using System.Net.Http.Json;

record Todo(int Id, int UserId, string Title, bool Completed);

HttpClient client = new HttpClient();

// TODO: try/catch; fetch List<Todo>; print count, completed count, first incomplete title
πŸ’‘ Hint

var todos = await client.GetFromJsonAsync<List<Todo>>("https://jsonplaceholder.typicode.com/todos");. Then todos.Count(t => t.Completed) and todos.First(t => !t.Completed).Title (or FirstOrDefault to be safe).

βœ… Solution
using System.Net.Http.Json;

record Todo(int Id, int UserId, string Title, bool Completed);

HttpClient client = new HttpClient();
string url = "https://jsonplaceholder.typicode.com/todos";

try
{
    List<Todo>? todos = await client.GetFromJsonAsync<List<Todo>>(url);

    if (todos is null || todos.Count == 0)
    {
        Console.WriteLine("No todos returned.");
        return;
    }

    Console.WriteLine($"Total todos: {todos.Count}");
    Console.WriteLine($"Completed: {todos.Count(t => t.Completed)}");

    Todo? firstOpen = todos.FirstOrDefault(t => !t.Completed);
    Console.WriteLine($"First incomplete: {firstOpen?.Title ?? "(none)"}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Could not reach the API: {ex.Message}");
}

Sample output:

Total todos: 200
Completed: 90
First incomplete: delectus aut autem

🎯 Quick Quiz

Question 1: What does GetFromJsonAsync<T> do in one call?

Question 2: An HTTP status code in the 4xx range means…

Question 3: Why reuse a single HttpClient instead of creating one per request?

Summary

πŸŽ‰ Key Takeaways

  • A web API serves data over HTTP at URLs (endpoints), usually as JSON; call it with HttpClient (all methods are async).
  • GetStringAsync returns raw text; GetFromJsonAsync<T> fetches and deserializes into your type in one step.
  • Check status codes (2xx success, 4xx client error, 5xx server error); use GetAsync + IsSuccessStatusCode for control.
  • Always handle failures with try/catch (HttpRequestException) β€” the network can always fail.
  • Reuse a single HttpClient (don't create one per request) to avoid socket exhaustion.

πŸ“š Additional Resources

πŸš€ What's Next?

Your code now talks to the wider world. Before the capstone, we'll make sure it stays correct as it grows. In Lesson 5.2: Unit Testing, you'll write automated tests that prove your code works β€” and keep it working.

πŸŽ‰ You're online!

Your programs can fetch live data from anywhere on the internet. Next: proving your code is correct with tests.