diff --git a/docs/fundamentals/networking/http/http-autoclient.md b/docs/fundamentals/networking/http/http-autoclient.md new file mode 100644 index 0000000000000..fb9d78d0a38eb --- /dev/null +++ b/docs/fundamentals/networking/http/http-autoclient.md @@ -0,0 +1,158 @@ +--- +title: Use the REST API HTTP client generator +description: Learn how to generate HttpClient-dependent code implementations of AutoClient decorated interfaces. +author: IEvangelist +ms.author: dapine +ms.date: 09/29/2023 +--- + +# Use the REST API HTTP client generator + +> [!NOTE] +> This API is experimental. It might change in subsequent versions of the library, and backwards compatibility is not guaranteed. + +The is a great way to consume REST APIs, but it's not without its challenges. One of the challenges is the amount of boilerplate code you need to write to consume the API. In this article, you learn how to use the [Microsoft.Extensions.Http.AutoClient](https://www.nuget.org/packages/Microsoft.Extensions.Http.AutoClient) NuGet package to decorate an interface and generate an HTTP client dependency. The AutoClient's underlying source generator generates the implementation of your interface, along with extension methods to register it into the dependency injection container. Additionally, the AutoClient generates telemetry for each HTTP request, which is sent with . + +## Use the `AutoClientAttribute` + +The is responsible for triggering the AutoClient generator to emit the corresponding implementation of the decorated interface. It accepts the `httpClientName` of the to be retrieved from the . Consider the following interface definition: + +:::code source="snippets/autoclient/IProductClient.cs"::: + +> [!TIP] +> The interface name must start with an `I`. The name is stripped of the leading `I`, and used as the for telemetry's . If the name ends in `Api` or `Client`, those are excluded. For example, if the interface is named `IProductClient`, the dependency name is `Product`. + +To override the calculated dependency name, use the `customDependencyName` parameter of the . + +:::code source="snippets/autoclient/IWidgetClient.cs"::: + +> [!NOTE] +> Both preceding code examples result in a compilation warning, as the interface doesn't define any HTTP methods. The warning is emitted by the AutoClient generator, and it's a reminder that the emitted implementations are be useless. + +## Define HTTP methods with verb attributes + +An empty interface isn't a useful abstraction. To define HTTP methods, you must use the HTTP verb attributes. Each HTTP method returns a where `T` is any of the following types: + +| Return type | Description | +|--|--| +| `Task` | The raw content of the response is returned as a string. | +| `Task` | When `T` is any serializable type, the response content is deserialized from JSON and returned. | +| `Task` | If you need the itself, as returned from , use this type. | + +When the content type of the HTTP response isn't `application/json` and the method's return type isn't `Task`, an exception is thrown. + +### HTTP verb attributes + +An HTTP method is defined using one of the following attributes: + +- +- +- +- +- +- +- + +Each attribute requires a `path` argument that routes to the underlying REST API, and it should be relative to the . The `path` can't contain query string parameters, instead the is used. From the perspective of telemetry, the `path` is used as the . + +HTTP methods decorated with any of the verb attributes must have a parameter and it should be the last parameter defined. The `CancellationToken` parameter is used to cancel the HTTP request. + +:::code source="snippets/autoclient/IUserClient.cs"::: + +The preceding code: + +- Defines an HTTP method using the . +- The `path` is `/api/users`. +- The method returns a where `T` is `User[]`. +- The method accepts an optional parameter that is assigned to `default` when an argument isn't provided. + +### Route parameters + +The URL may contain route parameters, for example, `"/api/users/{userId}"`. To define a route parameter the method must also accept a parameter with the same name, in this case, `userId`: + +:::code source="snippets/autoclient/IRouteParameterUserClient.cs"::: + +In the preceding code: + +- The `GetUserAsync` method has a route parameter named `userId`. +- The `userId` parameter is used in the `path` of the request, replacing the `{userId}` placeholder. + +### Telemetry request name + +The method name is used as the . If the method name includes the `Async` suffix, it's removed. For example, a method named `GetUsersAsync` is calculated as `"GetUsers"`. + +To override the name, use the `RequestName` property of each attribute of the [HTTP verb attributes](#http-verb-attributes). + +:::code source="snippets/autoclient/IRequestNameUserClient.cs"::: + +## HTTP payloads + +To send an HTTP payload with your request, use the on a method's parameter. If you don't pass any parameter to it, it treats the content type as JSON, serializing your parameter before sending. Otherwise, you define an explicit + and use it within the . + +:::code source="snippets/autoclient/IPayloadUserClient.cs"::: + +## HTTP headers + +There are two ways of sending headers with your HTTP request. One of them is best suited for headers that never change value (static headers). The other way is headers that change based on the parameters of your methods. + +### Static headers + +To define a static header, use the on your interface definition. Pass the header name and value to its constructor. + +You can also use more than one together and in methods as well. When the `StaticHeader` attribute is used on a method, that HTTP header is sent for that method only, whereas the interface-level `StaticHeader` attribute is sent for all methods. + +:::code source="snippets/autoclient/IStaticHeaderUserClient.cs"::: + +### Parameter headers + +Use the to define parameter-based headers, where you can receive the value for a header from the attributes of your method. Pass the header name to its constructor. + +The parameter may be of any type. When the header type is anything other than a `string`, the `.ToString()` method is called on the value of the parameter. + +:::code source="snippets/autoclient/IParameterHeaderUserClient.cs"::: + +## Query parameters + +Query parameters are defined using the on a method's parameter. All types are valid, and the query value relies on the `.ToString()` method to get the value of the parameter when not a `string` type. + +The is assigned from the name of the parameter. + +:::code source="snippets/autoclient/IQueryUserClient.cs"::: + +The `GetUsersAsync` method generates an HTTP request with a URL formatted as `/api/users?search={search}`. This format is used as the for telemetry. + +If you need to change the query key, you may call the `key` parameter-based constructor, . + +:::code source="snippets/autoclient/ICustomQueryUserClient.cs"::: + +The `GetUsersAsync` method generates an HTTP request with a URL formatted like `/api/users?customQueryKey={customQueryKey}`, as the key name was overridden to `customQueryKey`. + +## Dependency injection hooks + +Along with the interface's implementation, extension methods are generated to register the client in the dependency injection container. The name of the generated extension method is the same as your interface name, replacing the leading `I` with `Add`. + +For example, consider the following interface definition: + +:::code source="snippets/autoclient/ICompleteUserClient.cs"::: + +While the generator emits the implementation of the `ICompleteUserClient` interface, it also generates the `AddCompleteUserClient` extension method on the `IServiceCollection`. Consider the following example _Program.cs_ code: + +:::code source="snippets/autoclient/Program.cs" id="program"::: + +In the preceding example code: + +- The is used to create a . +- The is retrieved from the property, to call the extension method. + - The `AddHttpClient` extension method is called with the name of the to be registered, and a delegate to configure the instance. +- The `AddCompleteUserClient` extension method is called to register the `ICompleteUserClient` interface and its implementation. + +You can consume the client by injecting it into your service's constructor: + +:::code source="snippets/autoclient/UserService.cs"::: + +For more information, see [.NET dependency injection](../../../core/extensions/dependency-injection.md). + +The application is expected to output the following: + +:::code source="snippets/autoclient/Program.cs" id="output"::: diff --git a/docs/fundamentals/networking/http/httpclient.md b/docs/fundamentals/networking/http/httpclient.md index 5356ed67ae78b..85e44f4cf685e 100644 --- a/docs/fundamentals/networking/http/httpclient.md +++ b/docs/fundamentals/networking/http/httpclient.md @@ -377,4 +377,5 @@ For more information about configuring a proxy, see: - [Guidelines for using HttpClient](httpclient-guidelines.md) - [IHttpClientFactory with .NET](../../../core/extensions/httpclient-factory.md) - [Use HTTP/3 with HttpClient](../../../core/extensions/httpclient-http3.md) +- [REST API HTTP client generator](http-autoclient.md) - [Test web APIs with the HttpRepl](/aspnet/core/web-api/http-repl) diff --git a/docs/fundamentals/networking/http/snippets/autoclient/Address.cs b/docs/fundamentals/networking/http/snippets/autoclient/Address.cs new file mode 100644 index 0000000000000..e13535f3e60a5 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/Address.cs @@ -0,0 +1,6 @@ +public sealed record class Address( + string Street, + string? Suite, + string City, + string ZipCode, + Geo Geo); diff --git a/docs/fundamentals/networking/http/snippets/autoclient/Company.cs b/docs/fundamentals/networking/http/snippets/autoclient/Company.cs new file mode 100644 index 0000000000000..921088a4e2199 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/Company.cs @@ -0,0 +1,4 @@ +public sealed record class Company( + string Name, + string CatchPhrase, + string Bs); diff --git a/docs/fundamentals/networking/http/snippets/autoclient/Geo.cs b/docs/fundamentals/networking/http/snippets/autoclient/Geo.cs new file mode 100644 index 0000000000000..057b15c1c359d --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/Geo.cs @@ -0,0 +1 @@ +public sealed record class Geo(decimal Lat, decimal Lng); diff --git a/docs/fundamentals/networking/http/snippets/autoclient/ICompleteUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/ICompleteUserClient.cs new file mode 100644 index 0000000000000..22e698da93142 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/ICompleteUserClient.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(ICompleteUserClient))] +[StaticHeader("User-Agent", "dotnet-auto-client sample")] +public interface ICompleteUserClient +{ + [Get("users")] + public Task GetAllUsersAsync( + CancellationToken cancellationToken = default); + + [Get("users")] + public Task GetUserByNameAsync( + [Query] string name, + CancellationToken cancellationToken = default); + + [Get("users/{userId}")] + public Task GetUserByIdAsync( + int userId, + CancellationToken cancellationToken = default); + + [Post("users")] + [StaticHeader("X-CustomHeader", "custom-value")] + public Task CreateUserAsync( + [Body(BodyContentType.ApplicationJson)] User user, + CancellationToken cancellationToken = default); + + [Delete("user/{userId}")] + public Task DeleteUserAsync( + int userId, + [Header("If-None-Match")] string eTag, + CancellationToken cancellationToken = default); +} + diff --git a/docs/fundamentals/networking/http/snippets/autoclient/ICustomQueryUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/ICustomQueryUserClient.cs new file mode 100644 index 0000000000000..7533710586066 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/ICustomQueryUserClient.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(ICustomQueryUserClient))] +public interface ICustomQueryUserClient +{ + [Get("/api/users")] + public Task> GetUsersAsync( + [Query("customQueryKey")] string search, + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IParameterHeaderUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IParameterHeaderUserClient.cs new file mode 100644 index 0000000000000..41dee04bce218 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IParameterHeaderUserClient.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(IParameterHeaderUserClient), "User Service")] +public interface IParameterHeaderUserClient +{ + [Get("/api/users")] + public Task> GetUsersAsync( + [Header("X-MyHeader")] string myHeader, + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IPayloadUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IPayloadUserClient.cs new file mode 100644 index 0000000000000..a0d1a542f0b35 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IPayloadUserClient.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(IPayloadUserClient), "User Service")] +public interface IPayloadUserClient +{ + [Post("/api/users")] + public Task CreateUserAsync( + // The content type is JSON + // The parameter is serialized before sending + [Body] User user, + CancellationToken cancellationToken = default); + + [Put("/api/users/{userId}/displayName")] + public Task UpdateDisplayNameAsync( + string userId, + // The content type is text/plain + // The parameter is sent as is + [Body(BodyContentType.TextPlain)] string displayName, + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IProductClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IProductClient.cs new file mode 100644 index 0000000000000..d133a4e42c289 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IProductClient.cs @@ -0,0 +1,6 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(httpClientName: "GeneratedClient")] +public interface IProductClient +{ +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IQueryUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IQueryUserClient.cs new file mode 100644 index 0000000000000..15e582c3fffc9 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IQueryUserClient.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(IQueryUserClient))] +public interface IQueryUserClient +{ + [Get("/api/users")] + public Task> GetUsersAsync( + [Query] string search, + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IRequestNameUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IRequestNameUserClient.cs new file mode 100644 index 0000000000000..304d386d40fb3 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IRequestNameUserClient.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(IRequestNameUserClient), "User Service")] +public interface IRequestNameUserClient +{ + [Get("/api/users", RequestName = "CustomRequestName")] + public Task> GetUsersAsync( + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IRouteParameterUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IRouteParameterUserClient.cs new file mode 100644 index 0000000000000..25d9bb897610a --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IRouteParameterUserClient.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(IRouteParameterUserClient), "User Service")] +public interface IRouteParameterUserClient +{ + [Get("/api/users/{userId}")] + public Task GetUserAsync( + string userId, + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IStaticHeaderUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IStaticHeaderUserClient.cs new file mode 100644 index 0000000000000..6facadc4ee34a --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IStaticHeaderUserClient.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient(nameof(IStaticHeaderUserClient), "User Service")] +[StaticHeader("X-ForAllRequests", "GlobalHeaderValue")] +public interface IStaticHeaderUserClient +{ + [Get("/api/users")] + [StaticHeader("X-ForJustThisRequest", "RequestHeaderValue")] + public Task> GetUsersAsync( + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IUserClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IUserClient.cs new file mode 100644 index 0000000000000..3f5e2b5c265c5 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IUserClient.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient("GeneratedClient")] +public interface IUserClient +{ + [Get("/api/users")] + public Task GetUsersAsync( + CancellationToken cancellationToken = default); +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/IWidgetClient.cs b/docs/fundamentals/networking/http/snippets/autoclient/IWidgetClient.cs new file mode 100644 index 0000000000000..6f0c415399c2e --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/IWidgetClient.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Http.AutoClient; + +[AutoClient( + httpClientName: "GeneratedClient", + customDependencyName: "Widget Service")] +public interface IWidgetClient +{ +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/Program.cs b/docs/fundamentals/networking/http/snippets/autoclient/Program.cs new file mode 100644 index 0000000000000..9f2bd87fb1700 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/Program.cs @@ -0,0 +1,42 @@ +// +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + +// Add a named HTTP client "ICompleteUserClient". +builder.Services.AddHttpClient(nameof(ICompleteUserClient), options => +{ + options.BaseAddress = new("https://jsonplaceholder.typicode.com"); +}); + +builder.Services.AddCompleteUserClient(options => +{ + options.JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; +}); + +builder.Services.AddSingleton(); + +using IHost host = builder.Build(); + +UserService service = host.Services.GetRequiredService(); + +await service.ProcessUsersAsync(); + +host.Run(); +// + +// +// Sample output: +// CreateUserAsync: Created user +// 'Ada Lovelace (Email: 1st-computer-programmer@example.com, Id: 11)'... +// +// GetUserAsync: Received user +// 'Kurtis Weissnat (Email: Telly.Hoeger@billy.biz, Id: 7)'... +// +// GetUsersAsync: Received a total of 10 users... +// diff --git a/docs/fundamentals/networking/http/snippets/autoclient/User.cs b/docs/fundamentals/networking/http/snippets/autoclient/User.cs new file mode 100644 index 0000000000000..4494154fbcdcc --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/User.cs @@ -0,0 +1,12 @@ +public sealed record class User( + int? Id, + string Name, + string Username, + string Email, + Address Address, + string Phone, + string Website, + Company Company) +{ + public sealed override string ToString() => $"{Name} (Email: {Email}, Id: {Id})"; +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/UserService.cs b/docs/fundamentals/networking/http/snippets/autoclient/UserService.cs new file mode 100644 index 0000000000000..dc8ab81fc6a94 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/UserService.cs @@ -0,0 +1,49 @@ +using System.Net.Http.Json; + +internal sealed class UserService(ICompleteUserClient userClient) +{ + public async Task ProcessUsersAsync() + { + // Create a new user. + HttpResponseMessage response = await userClient.CreateUserAsync(new( + Id: null, /* Is populated upon successful HTTP POST when creating user */ + Name: "Ada Lovelace", + Username: "ada.lovelace", + Email: "1st-computer-programmer@example.com", + Address: new( + Street: "123 Engineer Lane", + Suite: null, + City: "London", + ZipCode: "EC1A", + Geo: new( + Lat: 51.509865m, Lng: -0.118092m)), + Phone: "+1234567890", + Website: "www.example.com", + Company: new( + Name: "Babbage, LLC.", + CatchPhrase: "works on my machine", + Bs: "This is the future"))); + + User? createdUser = await response.Content.ReadFromJsonAsync(); + + Console.WriteLine($""" + CreateUserAsync: Created user + '{createdUser}'... + + """); + + // Get user by id. + User receivedUser = await userClient.GetUserByIdAsync(7); + Console.WriteLine($""" + GetUserAsync: Received user + '{receivedUser}'... + + """); + + // Get list of all users. + User[] allUsers = await userClient.GetAllUsersAsync(); + Console.WriteLine($""" + GetUsersAsync: Received a total of {allUsers.Length} users... + """); + } +} diff --git a/docs/fundamentals/networking/http/snippets/autoclient/autoclient.csproj b/docs/fundamentals/networking/http/snippets/autoclient/autoclient.csproj new file mode 100644 index 0000000000000..f1cce7163a6a4 --- /dev/null +++ b/docs/fundamentals/networking/http/snippets/autoclient/autoclient.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + preview + + + + + + + diff --git a/docs/fundamentals/toc.yml b/docs/fundamentals/toc.yml index 44432f5af22d8..99800a3e2c043 100644 --- a/docs/fundamentals/toc.yml +++ b/docs/fundamentals/toc.yml @@ -830,6 +830,9 @@ items: - name: HTTP/3 with .NET href: ../core/extensions/httpclient-http3.md displayName: networking,http,http3,http/3,http3 with .net,http/3 with .net + - name: REST API HTTP client generator + href: networking/http/http-autoclient.md + displayName: networking,http,httpclient,auto,autoclient,auto-client,source generation,source generator,httpclient source generation,httpclient source generator - name: Rate limit an HTTP handler href: ../core/extensions/http-ratelimiter.md displayName: networking,http,rate limit,rate limiting,rate limit http,rate limiting http,rate limit http handler,rate limiting http handler