So far, BiblioTech has saved and recovered its own state: in plain text, in JSON, and in a
relational database. But no real application lives in isolation: it almost always needs to
communicate with other systems over the network, typically through a REST API — a
service that exposes data and operations over HTTP, with JSON as the usual exchange format. This
lesson goes deeper into System.Text.Json for scenarios more complex than those seen so far, and
introduces HttpClient, .NET's class for making HTTP requests, by consuming a (simulated)
external service that returns additional metadata for a book given its ISBN. This closes Module 5
(Working with Data), the last piece before Module 6, dedicated to more advanced topics of the
language and the platform.
Contents
- Review and going deeper:
System.Text.Jsonin more complex scenarios - Nested collections and naming options with
JsonNamingPolicy HttpClient: the entry point to HTTP services- Consuming a
GETand deserializing the response - Sending data with
POST - Best practices with
HttpClient: reuse andIHttpClientFactory - Handling network and HTTP errors
- BiblioTech queries external metadata for a book by ISBN
- Review and going deeper:
System.Text.Json in more complex scenarios
System.Text.Json in more complex scenariosThe Serialization lesson introduced JsonSerializer.Serialize/Deserialize on simple objects
and flat collections. The JSON returned by a real external API, however, usually has a richer
structure: objects nested inside other objects, lists inside an object, and naming conventions
that don't match PascalCase. This lesson picks up exactly those cases.
- Nested collections and naming options with
JsonNamingPolicy
JsonNamingPolicyConsider a typical response from an external book-metadata API, with a nested list of genres and a nested object holding publisher data:
{
"isbn": "978-84-376-0495-4",
"publisher": { "name": "Sudamericana", "country": "Argentina" },
"genres": ["Fiction", "Latin American Literature"],
"averageRating": 4.6
}To deserialize this structure, the nested classes are modeled to mirror the JSON as-is:
class Publisher
{
public string Name { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
}
class ExternalBookMetadata
{
public string Isbn { get; set; } = string.Empty;
public Publisher Publisher { get; set; } = new Publisher();
public List<string> Genres { get; set; } = new List<string>();
public double AverageRating { get; set; }
}JsonSerializer.Deserialize<ExternalBookMetadata>(json) automatically rebuilds both the nested
object (Publisher) and the list (Genres), with no extra code: EF Core, in the previous
lesson, and System.Text.Json, here, share the same philosophy of mapping entire structures by
convention.
Many real APIs use camelCase (averageRating, not AverageRating) in their JSON keys, instead
of the PascalCase usual for C# properties. Instead of annotating each property with
[JsonPropertyName] one by one (seen in the Serialization lesson),
JsonSerializerOptions.PropertyNamingPolicy applies the conversion to all properties at
once:
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
ExternalBookMetadata? metadata = JsonSerializer.Deserialize<ExternalBookMetadata>(json, options);
Console.WriteLine(metadata?.Publisher.Name); // "Sudamericana"
string generatedJson = JsonSerializer.Serialize(metadata, options);
// the keys are generated in camelCase: "isbn", "publisher", "genres", "averageRating"PropertyNamingPolicy works in both directions (serializing and deserializing), and it's the
recommended option over [JsonPropertyName] when an entire class follows the same naming
convention; reserve [JsonPropertyName] for one-off exceptions inside a class that otherwise
follows the default convention.
HttpClient: the entry point to HTTP services
HttpClient: the entry point to HTTP servicesHttpClient (from the System.Net.Http namespace) is .NET's class for making HTTP requests:
GET to fetch data, POST to send it, and the rest of the usual HTTP verbs (PUT, DELETE...).
Basic usage consists of creating an instance, optionally setting a BaseAddress, and making
requests against paths relative to it:
using System.Net.Http;
HttpClient client = new HttpClient
{
BaseAddress = new Uri("https://api.bibliotech-externo.example/")
};The System.Net.Http.Json package (included by default in modern .NET) adds extension methods
that combine the HTTP request with JSON deserialization in a single call: GetFromJsonAsync<T>,
PostAsJsonAsync<T>, skipping the intermediate step of reading the response body as text and
deserializing it separately.
- Consuming a
GET and deserializing the response
GET and deserializing the responseusing System.Net.Http.Json;
ExternalBookMetadata? metadata =
await client.GetFromJsonAsync<ExternalBookMetadata>("books/978-84-376-0495-4");
if (metadata is not null)
{
Console.WriteLine($"Publisher: {metadata.Publisher.Name} ({metadata.Publisher.Country})");
Console.WriteLine($"Average rating: {metadata.AverageRating}");
}GetFromJsonAsync<T> makes the GET request, checks that the response was successful, and
deserializes the JSON body directly into the given type T — the three steps that, with plain
JsonSerializer and an HttpClient without the .Json extension, would need to be written
separately (GetAsync, reading the body with ReadAsStringAsync, and
JsonSerializer.Deserialize).
- Sending data with
POST
POSTTo send data (for example, registering with an external service that BiblioTech has added a new
book to its catalog), PostAsJsonAsync<T> serializes the given object to JSON and sends it as
the request body:
class NewExternalBook
{
public string Isbn { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}
NewExternalBook newBook = new NewExternalBook
{
Isbn = "978-84-376-0497-8",
Title = "The Aleph"
};
HttpResponseMessage response = await client.PostAsJsonAsync("books", newBook);
if (response.IsSuccessStatusCode)
{
Console.WriteLine("Book registered with the external service.");
}response.IsSuccessStatusCode is true for any 2xx HTTP status code (200, 201, 204...); it's
the usual way to check whether a request succeeded without needing to read the exact numeric
code.
- Best practices with
HttpClient: reuse and IHttpClientFactory
HttpClient: reuse and IHttpClientFactoryUnlike SqliteConnection or StreamReader, HttpClient should not be created with using
for each request and discarded right after. Even though HttpClient also implements
IDisposable, creating a new instance per request can exhaust the operating system's available
sockets under load (each disposed HttpClient leaves its network connection in a closing state
that takes a while to fully release):
| Pattern | Right for... |
|---|---|
A single HttpClient instance, reused for the whole life of the application (or of a long-lived component) |
Console applications and simple scripts, like this course's |
IHttpClientFactory (injected via dependency injection) |
ASP.NET Core applications and other scenarios with many concurrent requests (Module 7) |
new HttpClient() inside a using, on every request |
Avoid: can exhaust available sockets under sustained load |
For this course's scope — a console application like BiblioTech — it's enough to create a
single HttpClient instance (for example, as a static readonly field on the class that
uses it) and reuse it across all calls; IHttpClientFactory solves the same problem in a more
sophisticated way in ASP.NET Core applications, where components' lifetimes are different, a
topic picked back up in Module 7 (Building Applications).
- Handling network and HTTP errors
An HTTP request can fail in two very different ways, worth telling apart:
| Type of failure | Exception / symptom | Example |
|---|---|---|
| Network failure | HttpRequestException (or another network exception) |
The server doesn't respond, no internet connection |
| Error HTTP response | The request completes, but with a 4xx/5xx code | The resource doesn't exist (404), server error (500) |
try
{
HttpResponseMessage response = await client.GetAsync("books/nonexistent-isbn");
response.EnsureSuccessStatusCode(); // throws HttpRequestException if the code isn't 2xx
ExternalBookMetadata? metadata =
await response.Content.ReadFromJsonAsync<ExternalBookMetadata>();
}
catch (HttpRequestException ex)
{
Console.WriteLine($"Error querying the external service: {ex.Message}");
}
catch (TaskCanceledException)
{
Console.WriteLine("The request timed out.");
}EnsureSuccessStatusCode() turns an HTTP error code into an HttpRequestException, so it can be
handled with the same try/catch as any other error (recalling Exception Handling from
Module 2), instead of manually checking the numeric code on every call. TaskCanceledException
can occur if the request exceeds the timeout configured on HttpClient.Timeout, a common
scenario when the external service doesn't respond in time.
- BiblioTech queries external metadata for a book by ISBN
Putting everything above together, Library gains a method that queries a (simulated) external
service — like the Task.Delay in the Asynchronous Programming lesson stood in for a slow
check — to enrich a Book's information with data BiblioTech doesn't store itself:
class Library
{
// ... Catalog, Members, Loans, earlier methods from the course unchanged ...
private static readonly HttpClient SharedHttpClient = new HttpClient
{
BaseAddress = new Uri("https://api.bibliotech-externo.example/")
};
private static readonly JsonSerializerOptions ExternalJsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public async Task<ExternalBookMetadata?> GetMetadataByIsbnAsync(string isbn)
{
try
{
HttpResponseMessage response = await SharedHttpClient.GetAsync($"books/{isbn}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ExternalBookMetadata>(ExternalJsonOptions);
}
catch (HttpRequestException ex)
{
Console.WriteLine($"Could not get external metadata for '{isbn}': {ex.Message}");
return null;
}
}
}Book hopscotch = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
library.AddItem(hopscotch);
ExternalBookMetadata? metadata = await library.GetMetadataByIsbnAsync(hopscotch.Isbn);
if (metadata is not null)
{
Console.WriteLine($"'{hopscotch.Title}' - Publisher: {metadata.Publisher.Name}, rating: {metadata.AverageRating}");
}
else
{
Console.WriteLine($"No external metadata available for '{hopscotch.Title}'.");
}GetMetadataByIsbnAsync follows the same pattern as LendBookAsync from the Asynchronous
Programming lesson: an async Task<T> method, with its obligatory Async in the name, that
wraps an I/O operation (before, a simulated Task.Delay; now, a real HTTP request) and handles
its own errors by returning null when the query fails, instead of propagating the exception up
to the caller.
sequenceDiagram
participant Main as Client code
participant Lib as Library
participant Http as HttpClient
participant Api as External API
Main->>Lib: await GetMetadataByIsbnAsync(isbn)
Lib->>Http: GetAsync("books/{isbn}")
Http->>Api: GET /books/{isbn}
Api-->>Http: 200 OK + JSON
Http-->>Lib: HttpResponseMessage
Lib->>Lib: ReadFromJsonAsync<ExternalBookMetadata>
Lib-->>Main: ExternalBookMetadata
Common Mistakes and Tips
- Creating a new
HttpClientper request withusing: under load, it exhausts the system's available sockets; reuse a single instance (or useIHttpClientFactoryin ASP.NET Core applications). - Not telling a network failure apart from an error HTTP response:
GetAsyncdoesn't throw an exception by itself on a404or a500— you have to callEnsureSuccessStatusCode()(or checkIsSuccessStatusCode) explicitly to treat them as errors. - Assuming external JSON follows PascalCase: most real APIs use
camelCaseorsnake_case; configurePropertyNamingPolicy(or[JsonPropertyName]for one-off cases) instead of assuming it will match C# property names. - Forgetting error handling on a network call: unlike reading a local file, an HTTP request
depends on an external system that might not respond, take too long, or return an error; any
code using
HttpClientin a real application needs its correspondingtry/catch. - Tip: to debug the exact JSON an external API returns before writing the destination
classes, it helps to first deserialize into a generic inspection type (like
JsonDocument, or even a plainstringwithWriteIndented) and look at its actual structure, instead of guessing the properties blindly.
Exercises
-
Define the
PublisherandExternalBookMetadataclasses as presented in this lesson. Deserialize the example JSON from section 2 usingJsonNamingPolicy.CamelCase, and showPublisher.Countryand the first element ofGenreson the console. -
Write a method
async Task<bool> BookExistsInExternalServiceAsync(HttpClient client, string isbn)that makes aGetAsync($"books/{isbn}")call and returnstrueifresponse.IsSuccessStatusCodeis true,falseotherwise, without throwing any exception for a404. -
Add the
GetMetadataByIsbnAsyncmethod from this lesson toLibrary. Call it from an asynchronousMainfor a book in the catalog, and handle both the success case (showing the publisher) and the case where the method returnsnull.
Solutions
string json =
"""
{
"isbn": "978-84-376-0495-4",
"publisher": { "name": "Sudamericana", "country": "Argentina" },
"genres": ["Fiction", "Latin American Literature"],
"averageRating": 4.6
}
""";
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
ExternalBookMetadata? metadata = JsonSerializer.Deserialize<ExternalBookMetadata>(json, options);
Console.WriteLine(metadata?.Publisher.Country); // "Argentina"
Console.WriteLine(metadata?.Genres[0]); // "Fiction"
async Task<bool> BookExistsInExternalServiceAsync(HttpClient client, string isbn)
{
try
{
HttpResponseMessage response = await client.GetAsync($"books/{isbn}");
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
}
Book book = library.Catalog.OfType<Book>().First();
ExternalBookMetadata? metadata = await library.GetMetadataByIsbnAsync(book.Isbn);
if (metadata is not null)
{
Console.WriteLine($"Publisher of '{book.Title}': {metadata.Publisher.Name}");
}
else
{
Console.WriteLine($"No external metadata available for '{book.Title}'.");
}
OfType<Book>() is a LINQ operator (from the LINQ lesson, Module 4) that filters a collection
down to elements of a specific type — here, only the Books from the mixed Catalog,
discarding the Magazines.
Conclusion
In this lesson you've gone deeper into System.Text.Json for nested structures and real naming
conventions, and learned to use HttpClient to consume an external REST API: GET and POST
requests, reuse best practices, and telling network errors apart from HTTP errors. Library can
now enrich its catalog with information from an external service, closing out Module 5 (Working
with Data): from the plain text file in the first lesson to an external REST API, by way of JSON,
ADO.NET, and Entity Framework, BiblioTech has stopped being an application that only lives in
memory.
Module 6 (Advanced Topics) revisits, in more depth, several tools this module has already used
in passing: reflection, which is literally the mechanism that lets JsonSerializer inspect a
class's properties with no manual mapping code; attributes, like [JsonPropertyName] or
[JsonDerivedType] seen in the Serialization lesson; memory management and the garbage
collector, which automatically frees the objects this module has been creating; and real
multithreading with Thread and Parallel, which completes what async/await left sketched
out in Module 4 about concurrency.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
