feat(01-02): implement health check round-trip with CORS and tutorial comments

- Add shared HealthResponse DTO in ChatAgent.Shared
- Add HealthController API endpoint with CORS policy for localhost:5200
- Add ChatApiClient typed HttpClient wrapper in WASM client
- Update Home.razor to display health check result on load
- Simplify MainLayout to minimal centered layout
- Add global imports for Services and Shared.Models
- Replace app.css with clean Phase 1 light theme styles
- Remove unused OpenAPI package from API project
- All files include tutorial-style inline comments (CODE-01)
This commit is contained in:
local
2026-03-27 22:58:19 +00:00
parent 4ef27598a0
commit 1fde98ca79
11 changed files with 484 additions and 185 deletions

View File

@@ -6,10 +6,6 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ChatAgent.Shared\ChatAgent.Shared.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,47 @@
// HealthController.cs -- API endpoint that proves the WASM-to-API connection works.
//
// This is the first controller in the project. It exists to verify that:
// 1. The API server starts and responds to HTTP requests
// 2. CORS allows the Blazor WASM client to call across origins
// 3. The shared HealthResponse DTO serializes/deserializes correctly
//
// In ASP.NET Core, a "controller" is a class that handles HTTP requests.
// The framework routes requests to controller methods based on attributes.
using ChatAgent.Shared.Models;
using Microsoft.AspNetCore.Mvc;
namespace ChatAgent.Api.Controllers
{
// [ApiController] enables several conveniences for API development:
// - Automatic HTTP 400 responses for invalid model state
// - Automatic inference of [FromBody] for complex types
// - Problem details responses for error status codes
// It signals "this class handles API requests, not HTML pages."
[ApiController]
// [Route("api/[controller]")] maps this class to the URL path /api/health.
// The [controller] token is replaced with the class name minus "Controller",
// so HealthController becomes "health" -> /api/health.
[Route("api/[controller]")]
public class HealthController : ControllerBase
{
// [HttpGet] maps this method to HTTP GET requests at the controller's route.
// When a client sends GET /api/health, ASP.NET Core calls this method.
// It returns an IActionResult, which gives us control over the HTTP status code.
[HttpGet]
public IActionResult Get()
{
// Return HTTP 200 OK with a HealthResponse body.
// The framework serializes the object to JSON automatically
// because this is an [ApiController].
// DateTime.UtcNow provides a live timestamp so the client can verify
// the response is fresh (not cached).
return Ok(new HealthResponse
{
Status = "healthy",
Timestamp = DateTime.UtcNow
});
}
}
}

View File

@@ -1,23 +1,59 @@
// Program.cs -- ASP.NET Core Web API entry point for ChatAgent.
//
// This is the backend server. In Phase 1, it only serves a health check endpoint.
// In later phases, it will proxy OpenAI API calls (keeping the API key server-side)
// and manage JSON file storage for conversation persistence.
//
// ASP.NET Core uses a "builder pattern": first configure services (DI container),
// then build the app, configure middleware pipeline, and run.
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// --- Service Registration (Dependency Injection container) ---
// AddControllers() registers MVC controller services so ASP.NET Core discovers
// classes decorated with [ApiController]. We use Controllers (not Minimal API)
// for explicit structure -- each controller is a separate file with clear routing (D-05).
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// AddCors() registers Cross-Origin Resource Sharing services.
// CORS is REQUIRED because the Blazor WASM client runs on a different origin
// (https://localhost:5200) than this API (https://localhost:7100).
// Browsers block cross-origin HTTP requests by default as a security measure.
// Without this policy, the client's fetch() calls to the API would be rejected.
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowBlazorClient", policy =>
{
policy
// Only allow requests from the Blazor WASM client origin
.WithOrigins("https://localhost:5200")
// Allow any HTTP header (Content-Type, Accept, etc.)
.AllowAnyHeader()
// Allow any HTTP method (GET, POST, PUT, DELETE, etc.)
.AllowAnyMethod();
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// --- Middleware Pipeline ---
// Middleware order matters in ASP.NET Core -- each middleware runs in the order
// it is registered. CORS must be applied before routing and authorization
// so that preflight (OPTIONS) requests are handled correctly.
app.UseHttpsRedirection();
// Apply the CORS policy globally -- every response includes the correct
// Access-Control-Allow-Origin header for the Blazor client's origin.
app.UseCors("AllowBlazorClient");
// UseAuthorization() enables the authorization middleware. Even though we have
// no auth in Phase 1, it is included because ASP.NET Core expects it in the
// pipeline when controllers are used. It is a no-op without [Authorize] attributes.
app.UseAuthorization();
// MapControllers() scans the assembly for all classes with [ApiController]
// and maps their routes. This is what connects HealthController's
// [Route("api/[controller]")] to the URL path /api/health.
app.MapControllers();
app.Run();

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.14" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,16 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
@* MainLayout.razor -- The root layout component for the application.
In Blazor, layout components wrap page content. Every routed page (@page)
is rendered inside the layout's @Body placeholder. This is similar to
_Layout.cshtml in MVC or master pages in Web Forms.
Phase 1 uses a minimal layout -- just centered content with padding.
Later phases will add a sidebar for conversation management.
*@
@* @inherits LayoutComponentBase makes this a layout component.
LayoutComponentBase provides the Body property, which is a RenderFragment
containing the routed page's content. Without this base class, @Body
would not be available. *@
@inherits LayoutComponentBase
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@* @Body is where the routed page content renders.
When the user navigates to "/", the Home.razor component's markup
appears here. When they navigate to another @page, that component
renders here instead. The layout stays the same -- only @Body changes. *@
@Body
</article>
</main>
</div>

View File

@@ -1,7 +1,86 @@
@page "/"
@* Home.razor -- The landing page for ChatAgent.
<PageTitle>Home</PageTitle>
@page "/" maps this component to the root URL. When a user navigates to "/",
the Blazor router renders this component inside MainLayout's @Body placeholder.
<h1>Hello, world!</h1>
This page demonstrates the health check round-trip:
1. On load, it calls the API's /api/health endpoint via ChatApiClient
2. Displays the server's health status and timestamp
3. Proves CORS is working (WASM on :5200 calling API on :7100)
*@
Welcome to your new app.
@* @page directive maps this component to a URL route.
"/" means this is the default/home page. *@
@page "/"
@* Import the service and model namespaces for this component.
These could also be in _Imports.razor for global access. *@
@using ChatAgent.Client.Services
@using ChatAgent.Shared.Models
@* @inject requests a service from the Dependency Injection (DI) container.
ChatApiClient was registered in Program.cs via AddHttpClient<ChatApiClient>.
Blazor creates one instance per component and injects it here. *@
@inject ChatApiClient ApiClient
<PageTitle>Chat Agent</PageTitle>
<h1>Chat Agent</h1>
@* Conditional rendering: Blazor re-renders the component after OnInitializedAsync completes.
We show different content based on the state of our data fields. *@
@if (_healthResponse is null && _error is null)
{
@* Loading state: OnInitializedAsync has not completed yet *@
<p class="loading">Checking API connection...</p>
}
else if (_healthResponse is not null)
{
@* Success state: API responded with a HealthResponse *@
<div class="health-status">
<p><strong>API Status:</strong> @_healthResponse.Status</p>
<p><strong>Server Time:</strong> @_healthResponse.Timestamp.ToString("yyyy-MM-dd HH:mm:ss UTC")</p>
</div>
}
else if (_error is not null)
{
@* Error state: the API call failed (network error, CORS blocked, server down, etc.) *@
<div class="error-message">
<p><strong>Connection Error:</strong> @_error</p>
</div>
}
@code {
// Private fields to hold the health check result or error message.
// Blazor components use private fields for state that drives the UI.
private HealthResponse? _healthResponse;
private string? _error;
/// <summary>
/// OnInitializedAsync is a Blazor lifecycle method called once when the component
/// is first rendered. It runs after the component receives its initial parameters.
///
/// Because it returns Task, Blazor awaits it and automatically calls
/// StateHasChanged() when it completes, triggering a re-render.
/// This means we do NOT need to call StateHasChanged() manually here.
///
/// The component renders twice: once immediately (showing "Checking..."),
/// and again after this method completes (showing the result or error).
/// </summary>
protected override async Task OnInitializedAsync()
{
try
{
// Call the API health endpoint via our typed HttpClient wrapper.
// This makes an HTTP GET to https://localhost:7100/api/health.
_healthResponse = await ApiClient.GetHealthAsync();
}
catch (Exception ex)
{
// Catch any exception (network error, CORS block, timeout, etc.)
// and store the message for display in the error UI.
_error = $"Failed to reach API: {ex.Message}";
}
}
}

View File

@@ -1,11 +1,47 @@
// Program.cs -- Blazor WebAssembly application entry point for ChatAgent.
//
// This is where the WASM client is configured and launched. Unlike a server-side
// ASP.NET Core app, a Blazor WASM app runs entirely in the browser. The
// WebAssemblyHostBuilder configures:
// - Root components (what gets rendered into the HTML page)
// - Services (dependency injection container, similar to server-side DI)
// - Configuration (reads from wwwroot/appsettings.json)
//
// This file will grow as we add more services in later phases (e.g., chat state management).
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using ChatAgent.Client;
using ChatAgent.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Add<App>("#app") tells Blazor to render the App component inside the
// <div id="app"> element in wwwroot/index.html. This is the "mount point"
// for the entire Blazor component tree.
builder.RootComponents.Add<App>("#app");
// HeadOutlet allows Razor components to modify <head> elements (e.g., <title>)
// using the <PageTitle> and <HeadContent> components. "head::after" means
// content is appended after existing head elements.
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// Read the API base URL from configuration.
// In Blazor WASM, configuration comes from wwwroot/appsettings.json.
// IMPORTANT: wwwroot/ files are PUBLIC -- they are downloaded to the browser.
// Never put secrets (API keys, passwords) in appsettings.json for a WASM app.
// The API key lives server-side in the ChatAgent.Api project.
var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7100";
// AddHttpClient<ChatApiClient> registers a typed HttpClient using IHttpClientFactory.
// IHttpClientFactory manages the underlying HttpMessageHandler lifetime to prevent
// socket exhaustion (a common problem with raw HttpClient in long-running apps).
// The lambda configures the client with the API base URL so ChatApiClient
// does not need to know the URL -- it is injected with a pre-configured HttpClient.
// In Blazor WASM, HttpClient uses the browser's Fetch API under the hood.
builder.Services.AddHttpClient<ChatApiClient>(client =>
{
client.BaseAddress = new Uri(apiBaseUrl);
});
await builder.Build().RunAsync();

View File

@@ -0,0 +1,53 @@
// ChatApiClient.cs -- Typed HttpClient wrapper for communicating with the ChatAgent API.
//
// WHY A TYPED CLIENT? Instead of injecting HttpClient directly into components,
// we wrap it in a dedicated service class. This follows the "typed client" pattern (D-04):
// - Components depend on ChatApiClient, not HttpClient (loose coupling)
// - API URL paths are centralized here, not scattered across components
// - Easy to mock for testing -- swap ChatApiClient, not HttpClient
// - IHttpClientFactory manages the underlying HttpClient lifetime
//
// In Blazor WASM, HttpClient is backed by the browser's Fetch API.
// The base URL is configured in Program.cs via AddHttpClient<ChatApiClient>.
using System.Net.Http.Json;
using ChatAgent.Shared.Models;
namespace ChatAgent.Client.Services
{
/// <summary>
/// Typed HttpClient for the ChatAgent API. Each public method maps to one API endpoint.
/// Constructor injection of HttpClient is provided by IHttpClientFactory,
/// which was configured in Program.cs with the API base URL.
/// </summary>
public class ChatApiClient
{
// The HttpClient instance is injected by the DI container (IHttpClientFactory).
// It is pre-configured with BaseAddress pointing to the API server.
private readonly HttpClient _httpClient;
/// <summary>
/// Constructor receives an HttpClient from IHttpClientFactory DI registration.
/// The client is already configured with the API base URL.
/// </summary>
public ChatApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <summary>
/// Calls GET /api/health on the API server and deserializes the JSON response
/// into a HealthResponse object. Returns null if deserialization fails.
///
/// GetFromJsonAsync is an extension method from System.Net.Http.Json that
/// combines the HTTP GET call and JSON deserialization into a single step.
/// It uses System.Text.Json internally (the built-in .NET JSON serializer).
/// </summary>
public async Task<HealthResponse?> GetHealthAsync()
{
// "api/health" is a relative URL -- it is appended to the BaseAddress
// configured in Program.cs (e.g., https://localhost:7100/api/health).
return await _httpClient.GetFromJsonAsync<HealthResponse>("api/health");
}
}
}

View File

@@ -1,4 +1,11 @@
@using System.Net.Http
@* _Imports.razor -- Global using directives for all .razor files in this project.
Any @using directive placed here is automatically available in every .razor file
in the ChatAgent.Client project. This avoids repeating common imports in each component.
It works like a "global usings" file but specifically for Razor components.
*@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@@ -8,3 +15,5 @@
@using Microsoft.JSInterop
@using ChatAgent.Client
@using ChatAgent.Client.Layout
@using ChatAgent.Client.Services
@using ChatAgent.Shared.Models

View File

@@ -1,41 +1,55 @@
/* app.css -- Application styles for ChatAgent (Phase 1).
*
* Phase 1 uses plain HTML/CSS (D-10) with a light theme (D-11).
* MudBlazor will be introduced in Phase 5 for UI polish.
* These styles provide a clean, minimal appearance for the health check page.
*/
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
background-color: #ffffff;
color: #333333;
}
h1:focus {
outline: none;
main {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
a, .btn-link {
color: #0071c1;
h1 {
color: #1a1a1a;
margin-bottom: 1.5rem;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
.health-status {
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background-color: #f9f9f9;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
.health-status p {
margin: 0.5rem 0;
}
.content {
padding-top: 1.1rem;
.error-message {
color: #d32f2f;
padding: 1rem;
border: 1px solid #d32f2f;
border-radius: 8px;
background-color: #fce4ec;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
.loading {
color: #666666;
font-style: italic;
}
/* Blazor error UI -- shown when an unhandled exception occurs.
* This is built into the Blazor template's index.html and should be kept. */
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
@@ -67,6 +81,8 @@ a, .btn-link {
content: "An error has occurred."
}
/* Loading progress indicator -- shown while the WASM runtime downloads.
* This SVG-based progress circle is defined in index.html. */
.loading-progress {
position: relative;
display: block;
@@ -99,16 +115,3 @@ a, .btn-link {
.loading-progress-text:after {
content: var(--blazor-load-percentage-text, "Loading");
}
code {
color: #c02d76;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

View File

@@ -0,0 +1,32 @@
// HealthResponse.cs -- Shared Data Transfer Object (DTO) for the health check endpoint.
//
// WHY SHARED? This class lives in the ChatAgent.Shared project, which is referenced by
// both the API (ChatAgent.Api) and the client (ChatAgent.Client). By sharing the type:
// - The API serializes a HealthResponse to JSON when responding to GET /api/health
// - The Client deserializes that same JSON back into a HealthResponse object
// - Both sides agree on the shape of the data -- no mismatched property names or types
//
// This is the "shared contract" pattern: one type definition, two consumers.
namespace ChatAgent.Shared.Models
{
/// <summary>
/// Data Transfer Object (DTO) returned by the API health check endpoint.
/// Contains the server's health status and current timestamp to prove
/// the response is live (not cached or stale).
/// </summary>
public class HealthResponse
{
/// <summary>
/// Server health status string (e.g., "healthy").
/// Initialized to empty string to avoid null warnings with nullable enabled.
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Server-side UTC timestamp at the moment the health check ran.
/// The client displays this to confirm the response is fresh.
/// </summary>
public DateTime Timestamp { get; set; }
}
}