Thursday, December 12, 2024

ASP.NET Core 9.0: Microsoft.AspNetCore.OpenApi and Swagger UI

With the release of .NET 9.0, you might have noticed for ASP.NET Core Web Application templates in Visual Studio, we no longer have Swagger UI configured.

It basically looks like below.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

WebApplication app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();

...

By default route to the OpenAPI specification will be https://<applicationUrl>/openapi/v1.json.

So far ASP.NET Core team hasn't announced (or I have not seen) their own UI for viewing OpenAPI specification. 

We can still use Swagger UI to view the OpenAPI specification.

First we need to install the package: Swashbuckle.AspNetCore.SwaggerUI.

Then we can do either one of this.

  • Update the Open API document route to the route Swagger UI expects
// Option 1: Update the Open API document route to the route Swagger UI expects
app.MapOpenApi("swagger/v1/swagger.json");
app.UseSwaggerUI();
Update the Open API document route to the route Swagger UI expects
  • Update Swagger UI to use the Open API document route
// Option 2: Update Swagger UI to use the Open API document route
app.MapOpenApi();
app.UseSwaggerUI(x =>
{
    x.SwaggerEndpoint("/openapi/v1.json""My API");
});
Update Swagger UI to use the Open API document route

Hope this helps.

Happy Coding.

Regards,
Jaliya

Monday, December 9, 2024

Use of PipelinePolicies in Azure.AI.OpenAI.AzureOpenAIClient

In this post let's see how we can make use of PipelinePolicies in AzureOpenAIClient. Even though we specifically talk about AzureOpenAIClient in this post, the concept will be the same among many Clients in Azure SDK.

When a client in Azure SDK sends a request to Azure Service/API, the request travels through a pipeline which consists of a set of policies that get to modify the request before it's being sent and observe the response after it's received.

In order to add a Custom policy, we need to implement the abstract class PipelinePolicy.

public class CustomLoggingPolicy : PipelinePolicy
{
    // For synchronous processing // Not implementing as most of the time we are calling async methods in the client
    public override void Process(PipelineMessage messageIReadOnlyList<PipelinePolicy> pipelineint currentIndex)
    {
        ProcessNext(messagepipelinecurrentIndex);
    }

    // For asynchronous processing
    public override async ValueTask ProcessAsync(PipelineMessage messageIReadOnlyList<PipelinePolicy> pipelineint currentIndex)
    {
        Console.WriteLine($"Sending Request to {message.Request.Method}{message.Request.Uri}");

        await ProcessNextAsync(messagepipelinecurrentIndex);

        if (message.Response?.IsError == true)
        {
            Console.WriteLine($"Request to {message.Request.Method}{message.Request.Uri} failed.");
            Console.WriteLine(new
            {
                message.Response.ReasonPhrase,
                message.Response.Content,
            });
        }
        else
        {
            Console.WriteLine($"Request to {message.Request.Method}{message.Request.Uri} completed.");
        }
    }
}

Here I have added CustomLoggingPolicy for demo purposes. A key thing to remember is, we need to call the  ProcessNext/ProcessNextAsync method to pass the control to the next PipelinePolicy.

We can now make use of the CustomLoggingPolicy as follows.

string deploymentName = "gpt-4o";

AzureOpenAIClientOptions azureOpenAIClientOptions = new();
azureOpenAIClientOptions.AddPolicy(new CustomLoggingPolicy()PipelinePosition.BeforeTransport);

AzureOpenAIClient azureOpenAiClient =
    new(new Uri("<openai-endpoint>")new DefaultAzureCredential()azureOpenAIClientOptions);

ChatClient chatClient = azureOpenAiClient.GetChatClient(deploymentName);

List<ChatMessage> chatMessages =
[
    // messages
];

ChatCompletionOptions chatCompletionOptions = new()
{
    // options
};

ClientResult<ChatCompletion> clientResult
   = await chatClient.CompleteChatAsync(chatMessageschatCompletionOptions);
CustomLoggingPolicy
Say you want to add a Custom Retry policy, then you can implement a class that inherits from ClientRetryPolicy and override ShouldRetryAsync method.

public class CustomClientRetryPolicy : ClientRetryPolicy
{
    protected override async ValueTask<bool> ShouldRetryAsync(PipelineMessage messageExceptionexception)
    {
        if (message.Response.Status == (int)HttpStatusCode.RequestEntityTooLarge)
        {
            return false;
        }

        return await base.ShouldRetryAsync(messageexception);
    }
}
Usage:
AzureOpenAIClientOptions azureOpenAIClientOptions = new();
...
azureOpenAIClientOptions.RetryPolicy = new CustomClientRetryPolicy();

Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, December 4, 2024

EF Core 9.0: UseSeeding and UseAsyncSeeding

EF Core 9.0 (EF 9) introduced new methods UseSeeding and UseAsyncSeeding to seed the initial data.

Let's have a look at an example.

Consider the following MyDbContext.

public record Blog
{
    public int Id { getset}

    public string Name { getset}
}

public class MyDbContext : DbContext
{
    public DbSet<Blog> Blogs { getset}

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer("<ConnectionString>");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>(x =>
        {
            x.HasData
            (
               new Blog
               {
                   Id = 1,
                   Name = "Some Blog"
               }
            );
        });
    }
}

Here in the above example, some data is seeded through Model-managed data which is handly when we want to include seed data in migrations as well and it was available for as far as I can remember.

Now let's see where UseSeeding and UseAsyncSeeding come in.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer("<ConnectionString>")
        .UseSeeding((context_) =>
        {
            Blogblog = context.Set<Blog>().SingleOrDefault(b => b.Name == "Some Other Blog");
            if (blog is null)
            {
                context.Set<Blog>().Add(new Blog { Name = "Some Other Blog" });
                context.SaveChanges();
            }
        })
        .UseAsyncSeeding(async (context_cancellationToken) =>
        {
            Blogblog = await context.Set<Blog>().SingleOrDefaultAsync(b => b.Name == "Some Other Blog"cancellationToken);
            if (blog is null)
            {
                context.Set<Blog>().Add(new Blog { Name = "Some Other Blog" });
                await context.SaveChangesAsync(cancellationToken);
            }
        });
}

As you can see, the new seeding methods are available as part of configuring DbContextOptions. Unlike Model-managed data, you can seed data for multiple DbSets all from a single place. These methods will get called as part of EnsureCreated operation, Migrate and dotnet ef database update command, even if there are no model changes and no migrations were applied.

Now let's see this in action.

using var context = new MyDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

foreach (Blog blog in context.Blogs)
{
    Console.WriteLine(blog.Name);
}

// Output:
//Some Blog
//Some Other Blog

And a note from the official documentation:

UseSeeding is called from the EnsureCreated method, and UseAsyncSeeding is called from the EnsureCreatedAsync method. When using this feature, it is recommended to implement both UseSeeding and UseAsyncSeeding methods using similar logic, even if the code using EF is asynchronous. EF Core tooling currently relies on the synchronous version of the method and will not seed the database correctly if the UseSeeding method is not implemented.

More read:
   EF Core: 
Data Seeding

Happy Coding.

Regards,
Jaliya

Friday, November 29, 2024

Blazor Web App: Adding Custom Claims to Current User

I recently had a requirement where I want to add Custom Claims to the current user in a Blazor Web Application. I used IClaimsTransformation, but it doesn't work with Blazor. Instead, the correct option for Blazor is to implement a custom AuthenticationStateProvider.

public class ApplicationAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly AuthenticationStateProvider _authenticationStateProvider;
    private readonly IUserService _userService;

    public ApplicationAuthenticationStateProvider(AuthenticationStateProvider AuthenticationStateProvider, IUserService userService)
    {
        _authenticationStateProvider = AuthenticationStateProvider;
        _userService = userService;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        AuthenticationState authenticationState =  await _authenticationStateProvider.GetAuthenticationStateAsync();
        if (authenticationState.User.Identity?.IsAuthenticated != true)
        {
            return authenticationState;
        }

        // Check if user already has the UserId claim
        if (authenticationState.User.HasClaim(x => x.Type == ApplicationClaimTypes.UserId))
        {
            return authenticationState;
        }

        // TODO: Get the user from the database using current claims and IUserService
        string applicationUserId = "someUserId";

        ClaimsIdentity claimsIdentity = new ClaimsIdentity();
        claimsIdentity.AddClaim(new Claim(ApplicationClaimTypes.UserId, applicationUserId));

        authenticationState.User.AddIdentity(claimsIdentity);

        return authenticationState;
    }
}

Register ApplicationAuthenticationStateProvider,

builder.Services.AddScoped<ApplicationAuthenticationStateProvider>();

Use,

// This is some service where you want to use authenticationState
public class BlazorIdentityService : IIdentityService
{
    private readonly ApplicationAuthenticationStateProvider _applicationAuthenticationStateProvider;

    public BlazorIdentityService(ApplicationAuthenticationStateProvider applicationAuthenticationStateProvider)
    {
        _applicationAuthenticationStateProvider = applicationAuthenticationStateProvider;
    }

    public async Task<string> GetCurrentUserId()
    {
        AuthenticationState authenticationState =  await _applicationAuthenticationStateProvider?.GetAuthenticationStateAsync();

        return authenticationState.User?.Claims?.SingleOrDefault(x => x.Type == ApplicationClaimTypes.UserId)?.Value;
    }
}

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, November 26, 2024

Azure APIM Backends with OpenAPI Specifications: Importance of operationId

I recently faced an issue in one of the APIs where as part of importing OpenAPI to Azure APIM, it started throwing an error: "(ValidationError) Operation with the same method and URL template already exists".

Let's have a look at a Minimal Reproducible Example (MRE).

Say I have the following ASP.NET Core API controller that is getting exposed in OpenAPI Spec.
[Route("api/some/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long-resources")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet]
    public IEnumerable<string> Get()
    {
        return ["value1""value2"];
    }

    [HttpGet("{id}")]
    public string Get(int id)
    {
        return "value";
    }
}
When I import this to APIM, it's getting imported.

Note the operationId
OpenAPI Specification
Since the code hasn't explicitly specified an operationId, APIM is generating the operationId by following a convention (see Normalization rules for operationId).

Now let's remove the first endpoint and try to import again.
[Route("api/some/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long-resources")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet("{id}")]
    public string Get(int id)
    {
        return "value";
    }
}
This time I am getting the error.
(ValidationError) Operation with the same method and URL template already exists
Since we no longer have multiple operations, now for the operationId no de-duplication suffix will be used, but the newly generated operationId is the same operationId that was previously used for"GET: /api/some/very/.../long-resources" endpoint.

We can fix this by adding operationId in the code, as follows.
[Route("api/some/very/very/very/very/very/very/very/very/very/very/very/very/very/very/very/long-resources")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet("{id}", Name = "GetValueById")]
    public string Get(int id)
    {
        return "value";
    }
}
Explicit operationId
So as a practice, we should always consider explicitly specifying operationId in our code rather than letting APIM auto-generate one.And this is going to be very important if you are using APIOps and has custom APIM policy overrides for operations. Because the policies are maintained by operationId and if operationId generation convention is changed for some reason, your APIOps deployments will fail.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, November 14, 2024

Blazor Web App: Authentication Redirect URI is not HTTPS

.NET 9 is finally out and I was playing around with Blazor. I was setting up Authentication in a .NET 9 Blazor Web App. The authentication is configured with AzureAD, and locally everything was working fine. The application was running on HTTPS and the redirect_uri was HTTPS too.  

When the application was deployed to Azure, the Authentication was failing, because the redirect_uri was HTTP.  In Azure AD App Registration I configured it with HTTPS (HTTP is allowed only when using localhost). The application was running inside a Linux Container in an Azure Web App.

In order for redirect_uri to be HTTPS, I had to do the following:

1. Enable UseForwardedHeaders

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});
// Other service registrations

WebApplication app = builder.Build();

// Note: Forwarded Headers Middleware should run before other middleware. 
// This ordering ensures that the middleware relying on forwarded headers information can consume the header values for processing. 
// Forwarded Headers Middleware can run after diagnostics and error handling, but it MUST BE RUN before calling UseHsts
app.UseForwardedHeaders();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error"createScopeForErrorstrue);
    app.UseHsts();
}

app.UseHttpsRedirection();
// Other middleware

app.Run();

2. Add the following app setting in Azure (More: Forward the scheme for Linux and non-IIS reverse proxies)

{
  "name""ASPNETCORE_FORWARDEDHEADERS_ENABLED",
  "value""true",
  "slotSetting"false
}

And that did it.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, November 6, 2024

Azure APIM: Policy Expression to Read application/x-www-form-urlencoded Request Data

Recently I had a requirement where a particular Client sends some request data as application/x-www-form-urlencoded and needed to get those moved to the request header before it gets forwarded to the Backend.

In this post, let's see how we can read request data sent via application/x-www-form-urlencoded.

If we have a look at .NET Framework types allowed in policy expressions, we have access to System.Net.WebUtility.

So we can make use of that as follows:
<policies>
    <inbound>
        <!--Extract URL Encoded Form Data (if any)-->
        <set-variable name="serializedFormData" value="@{
            var formData = new System.Collections.Generic.Dictionary<String, String>();
            if(context.Request.Headers.GetValueOrDefault("Content-Type", "") != "application/x-www-form-urlencoded")
            {
                return JsonConvert.SerializeObject(formData);
            }

            string encodedBody = context.Request.Body.As<String>(preserveContent: true);
            string decodedBody = System.Net.WebUtility.UrlDecode(encodedBody);
            foreach (string key in decodedBody.Split('&'))
            {
                string[] keyValue = key.Split('=');
                formData.Add(keyValue[0], keyValue[1]);
            }

            return JsonConvert.SerializeObject(formData);
        }" />
        <!--Check if the interested headers are sent in the form data-->
        <set-variable name="isRequiredHeadersSentInFormData" value="@{
            string serializedFormData = context.Variables.GetValueOrDefault<String>("serializedFormData");
            System.Collections.Generic.Dictionary<String, String> formData = 
                JsonConvert.DeserializeObject<System.Collections.Generic.Dictionary<String, String>>(serializedFormData);

             return formData.ContainsKey("key1") && formData.ContainsKey("key2");
        }" />
        <!--Set the headers from the form data if present-->
        <choose>
            <when condition="@(context.Variables.GetValueOrDefault<bool>("isRequiredHeadersSentInFormData"))">
                <set-header name="x-key1" exists-action="override">
                    <value>@{
                        string serializedFormData = context.Variables.GetValueOrDefault<String>("serializedFormData");
                        System.Collections.Generic.Dictionary<String, String> formData = 
                            JsonConvert.DeserializeObject<System.Collections.Generic.Dictionary<String, String>>(serializedFormData);

                        return formData["key1"];
                    }</value>
                </set-header>
                <set-header name="x-key2" exists-action="override">
                    <value>@{
                        string serializedFormData = context.Variables.GetValueOrDefault<String>("serializedFormData");
                        System.Collections.Generic.Dictionary<String, String> formData = 
                            JsonConvert.DeserializeObject<System.Collections.Generic.Dictionary<String, String>>(serializedFormData);

                        return formData["key2"];
                    }</value>
                </set-header>
            </when>
        </choose>
        ...
    </inbound>
    ...
</policies>
And now we test with trace, we can see the request is correctly being transformed.
Trace
Hope this helps.

Happy Coding.

Regards,
Jaliya

Wednesday, October 23, 2024

.NET 9.0: Out-of-order Metadata Reading in System.Text.Json

Release of .NET 9.0 is like 3 weeks away and in this post, let's have a look at one of the most awaited System.Text.Json features.

Let's have a look at an example.

Consider the below types.
[JsonDerivedType(typeof(Student)"student")]
record Person(string Name);

record Student(string Namestring StudentId) : Person(Name);
The following code will throw an error on Deserialize.
using System.Text.Json.Serialization;
using System.Text.Json;

JsonSerializerOptions options = new();

Person person = new Employee("John Doe""STU001");
string serializedPerson = JsonSerializer.Serialize(personoptions);
// {"$type":"student","StudentId":"STU001","Name":"John Doe"}

// Change the order of $type
serializedPerson = """
{
    
"StudentId":"STU001",
    
"Name":"John Doe",
    
"$type":"student"
}
"""
;

person = JsonSerializer.Deserialize<Person>(serializedPersonoptions)!; // Exception
// System.Text.Json.JsonException: The metadata property is either not supported by the type or is not the first property in the deserialized JSON object.
The reason is (as the exception says) that the metadata property $type is not the first property in the JSON string, and it has to be. While there is a reason for that requirement, until .NET 9, it was a known limitation (see #dotnet/runtime/issues/72604).

Finally, in .NET 9, we can enable AllowOutOfOrderMetadataProperties in JsonSerializerOptions.
JsonSerializerOptions options = new()
{
    AllowOutOfOrderMetadataProperties = true
};
And now the above would work just fine.

Hope this helps.

Happy Coding.

Regards,
Jaliya