diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 35c474b..fcbe88e 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -25,6 +25,7 @@ on: - "csharp-language/modern-patterns-result-pipeline/**" - "dotnet-8-essentials/background-jobs-hostedservice-queues/**" - "dotnet-8-essentials/configuration-secrets-environments/**" + - "dotnet-8-essentials/core-features-get-started/**" - ".github/workflows/build-samples.yml" pull_request: @@ -50,6 +51,7 @@ on: - "csharp-language/modern-patterns-result-pipeline/**" - "dotnet-8-essentials/background-jobs-hostedservice-queues/**" - "dotnet-8-essentials/configuration-secrets-environments/**" + - "dotnet-8-essentials/core-features-get-started/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -1232,3 +1234,197 @@ jobs: echo "Secret-like CI value leaked into an HTTP response." exit 1 fi + + test-native-aot-essentials: + name: Test Native AOT essentials sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Install Native AOT prerequisites + shell: bash + run: | + sudo apt-get update + + sudo apt-get install \ + --yes \ + clang \ + zlib1g-dev + + - name: Restore + run: > + dotnet restore + dotnet-8-essentials/core-features-get-started/NativeAotEssentialsMinimal.slnx + + - name: Build + run: > + dotnet build + dotnet-8-essentials/core-features-get-started/NativeAotEssentialsMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + dotnet-8-essentials/core-features-get-started/NativeAotEssentialsMinimal.slnx + --configuration Release + --no-build + + - name: Verify application has no direct packages + shell: bash + run: | + output="$( + dotnet list \ + dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/NativeAotEssentialsMinimal.csproj \ + package + )" + + printf '%s\n' "${output}" + + if grep \ + --extended-regexp \ + --quiet \ + '&1 | + tee "${publish_log}" + + test -x \ + "${publish_dir}/NativeAotEssentialsMinimal" + + if grep \ + --extended-regexp \ + --ignore-case \ + 'warning (IL[0-9]+|CS[0-9]+|NETSDK[0-9]+)' \ + "${publish_log}"; then + echo "Native AOT publish produced a compiler, SDK, trim, or AOT warning." + exit 1 + fi + + - name: Smoke-test native executable + shell: bash + run: | + publish_dir="${RUNNER_TEMP}/native-aot-essentials" + app_log="${RUNNER_TEMP}/native-aot-essentials.log" + + ASPNETCORE_URLS="http://127.0.0.1:5098" \ + DOTNET_ENVIRONMENT="Production" \ + "${publish_dir}/NativeAotEssentialsMinimal" \ + >"${app_log}" 2>&1 & + + app_pid="$!" + + cleanup() { + kill "${app_pid}" 2>/dev/null || true + wait "${app_pid}" 2>/dev/null || true + } + + trap cleanup EXIT + + for attempt in $(seq 1 60); do + if curl \ + --fail \ + --silent \ + http://127.0.0.1:5098/ \ + >"${RUNNER_TEMP}/native-aot-root.json"; then + break + fi + + if ! kill -0 "${app_pid}" 2>/dev/null; then + cat "${app_log}" + exit 1 + fi + + sleep 0.25 + done + + root="$( + cat "${RUNNER_TEMP}/native-aot-root.json" + )" + + expected_root='{"sample":"native-aot-essentials","builder":"CreateSlimBuilder","json":"source-generated"}' + + if [[ "${root}" != "${expected_root}" ]]; then + printf 'Unexpected root response:\n%s\n' "${root}" + exit 1 + fi + + runtime="$( + curl \ + --fail \ + --silent \ + http://127.0.0.1:5098/runtime + )" + + printf '%s\n' "${runtime}" + + printf '%s\n' "${runtime}" | + grep \ + --fixed-strings \ + --quiet \ + '"dynamicCodeSupported":false' + + printf '%s\n' "${runtime}" | + grep \ + --fixed-strings \ + --quiet \ + '"dynamicCodeCompiled":false' + + echo_response="$( + curl \ + --fail \ + --silent \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"message":" hello native aot "}' \ + http://127.0.0.1:5098/echo + )" + + expected_echo='{"message":"HELLO NATIVE AOT","length":16}' + + if [[ "${echo_response}" != "${expected_echo}" ]]; then + printf 'Unexpected echo response:\n%s\n' "${echo_response}" + exit 1 + fi + + invalid_status="$( + curl \ + --silent \ + --output "${RUNNER_TEMP}/native-aot-invalid.json" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"message":" "}' \ + http://127.0.0.1:5098/echo + )" + + test "${invalid_status}" = "400" diff --git a/README.md b/README.md index b9e4732..b29b9bd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`csharp-language/modern-patterns-result-pipeline`](csharp-language/modern-patterns-result-pipeline/) | Focused .NET 10 console companion locked to C# 12.0, demonstrating a custom Result type, safe and diagnostic errors, Map/Bind/BindAsync composition, invariant text-import validation, short-circuit persistence, cancellation, deterministic output, and tests | [C# 12 Functional Patterns: Result Type, Error Handling & Composable Pipeline Testing](https://www.dotnet-guide.com/tutorials/csharp-language/modern-patterns-result-pipeline/) | | [`dotnet-8-essentials/background-jobs-hostedservice-queues`](dotnet-8-essentials/background-jobs-hostedservice-queues/) | Focused ASP.NET Core Minimal API demonstrating a bounded Channel queue, asynchronous backpressure, a BackgroundService consumer, fresh scoped handlers, safe in-memory job status, exception isolation, cancellation, and integration tests | [.NET 8 Background Jobs: IBackgroundTaskQueue, BackgroundService & Production-Ready Patterns](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/background-jobs-hostedservice-queues/) | | [`dotnet-8-essentials/configuration-secrets-environments`](dotnet-8-essentials/configuration-secrets-environments/) | Focused .NET 10 Minimal API demonstrating layered configuration precedence, prefixed environment variables, command-line overrides, strongly typed options, startup validation, User Secrets metadata, a lightweight feature flag, and safe Development-only diagnostics | [.NET 8 Configuration & Secrets Management: Typed Options, User Secrets & Feature Flags](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/configuration-secrets-environments/) | +| [`dotnet-8-essentials/core-features-get-started`](dotnet-8-essentials/core-features-get-started/) | Focused .NET 10 Native AOT Minimal API demonstrating CreateSlimBuilder, source-generated JSON, typed DI, AOT-safe endpoints, analyzer-aware publishing, and direct native-binary smoke testing | [.NET 8 Essentials: Core Features & Getting Started](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/core-features-get-started/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -175,6 +176,24 @@ tutorials/ | `-- ConfigPrecedenceMinimal.Tests/ | |-- ConfigPrecedenceMinimal.Tests.csproj | `-- ConfigurationTests.cs +| `-- core-features-get-started/ +| |-- NativeAotEssentialsMinimal.slnx +| |-- README.md +| |-- src/ +| | `-- NativeAotEssentialsMinimal/ +| | |-- NativeAotEssentialsMinimal.csproj +| | |-- Program.cs +| | |-- Models/ +| | | `-- ApiModels.cs +| | |-- Serialization/ +| | | `-- AppJsonSerializerContext.cs +| | `-- Services/ +| | |-- ITextTransformer.cs +| | `-- TextTransformer.cs +| `-- tests/ +| `-- NativeAotEssentialsMinimal.Tests/ +| |-- NativeAotEssentialsMinimal.Tests.csproj +| `-- NativeAotEssentialsTests.cs |-- blazor/ | |-- create-interactive-ui-csharp-12/ | | |-- BlazorTodoMinimal.slnx diff --git a/dotnet-8-essentials/core-features-get-started/NativeAotEssentialsMinimal.slnx b/dotnet-8-essentials/core-features-get-started/NativeAotEssentialsMinimal.slnx new file mode 100644 index 0000000..af15706 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/NativeAotEssentialsMinimal.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotnet-8-essentials/core-features-get-started/README.md b/dotnet-8-essentials/core-features-get-started/README.md new file mode 100644 index 0000000..827d52d --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/README.md @@ -0,0 +1,233 @@ +# Native AOT Minimal API Essentials + +A focused ASP.NET Core companion demonstrating `CreateSlimBuilder`, +source-generated JSON metadata, an AOT-compatible Minimal API, typed DI, +warning-aware native publishing, and direct execution of the resulting Linux +native executable. + +## Full tutorial + +[.NET 8 Essentials: Core Features & Getting Started](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/core-features-get-started/) + +## Version note + +The full tutorial explains features introduced with the .NET 8 generation. + +This companion targets .NET 10 because that is the DOTNET GUIDE repository's +current LTS baseline. + +As of August 2026: + +```text +.NET 10 = active LTS +.NET 8 = maintenance +.NET 8 end of support = November 10, 2026 +``` + +The Native AOT concepts demonstrated here remain directly relevant. + +## Focus + +```text +CreateSlimBuilder + -> AOT-compatible DI + -> Minimal API + -> source-generated JSON + -> Native AOT publish + -> direct native executable +``` + +This is not another Todo CRUD sample. + +## Why CreateSlimBuilder? + +The standard: + +```csharp +WebApplication.CreateBuilder(args) +``` + +is still the normal choice for many applications. + +This sample uses: + +```csharp +WebApplication.CreateSlimBuilder(args) +``` + +because it starts with a smaller ASP.NET Core feature set designed for +trim/AOT-oriented applications. + +It does not imply that every API should use the slim builder. + +## JSON source generation + +Every request and response body type is registered in: + +```text +AppJsonSerializerContext +``` + +The application does not add a reflection-based serializer fallback. + +Native AOT requires application behavior to be statically analyzable; open-ended +runtime reflection and dynamic code generation are constrained. + +## Endpoints + +### Sample metadata + +```http +GET / +``` + +Expected: + +```json +{"sample":"native-aot-essentials","builder":"CreateSlimBuilder","json":"source-generated"} +``` + +### Runtime capability + +```http +GET /runtime +``` + +The Native AOT executable should report: + +```text +dynamicCodeSupported = false +dynamicCodeCompiled = false +``` + +This is a runtime capability check, not a benchmark. + +### Typed echo + +```http +POST /echo +Content-Type: application/json + +{ + "message": " hello native aot " +} +``` + +Expected: + +```json +{"message":"HELLO NATIVE AOT","length":16} +``` + +## Native AOT does not mean universally faster + +This repository sample intentionally makes no claim such as: + +```text +50% faster +70% faster +10 MB executable +half the memory +``` + +Actual results depend on: + +- workload; +- dependencies; +- platform; +- runtime version; +- publish settings; +- request mix; +- measurement method. + +Measure your own application. + +## Restore, build, and test + +```powershell +dotnet restore ` + .\NativeAotEssentialsMinimal.slnx + +dotnet build ` + .\NativeAotEssentialsMinimal.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\NativeAotEssentialsMinimal.slnx ` + --configuration Release ` + --no-build +``` + +## Native publish + +Linux x64 example: + +```bash +dotnet publish \ + src/NativeAotEssentialsMinimal/NativeAotEssentialsMinimal.csproj \ + --configuration Release \ + --runtime linux-x64 +``` + +Native AOT requires native build prerequisites for the host platform. + +Native AOT isn't an arbitrary cross-compilation system. + +## AOT limitations + +Review AOT/trimming warnings rather than blindly suppressing them. + +Common problem areas include: + +- dynamic assembly loading; +- runtime code generation; +- unbounded reflection; +- libraries that aren't trim/AOT compatible. + +## Deliberately omitted + +- controllers; +- MVC; +- database access; +- OpenAPI packages; +- Swagger UI; +- authentication; +- reflection fallbacks; +- benchmarking; +- Docker; +- cloud deployment. + +## Project structure + +```text +NativeAotEssentialsMinimal.slnx +README.md +src/ +`-- NativeAotEssentialsMinimal/ + |-- NativeAotEssentialsMinimal.csproj + |-- Program.cs + |-- Models/ + | `-- ApiModels.cs + |-- Serialization/ + | `-- AppJsonSerializerContext.cs + `-- Services/ + |-- ITextTransformer.cs + `-- TextTransformer.cs +tests/ +`-- NativeAotEssentialsMinimal.Tests/ + |-- NativeAotEssentialsMinimal.Tests.csproj + `-- NativeAotEssentialsTests.cs +``` + +## Verification + +- Target framework: .NET 10 +- Publish model: Native AOT +- Application NuGet packages: none +- Managed tests: 8 +- JSON metadata: source generated +- Native CI RID: linux-x64 +- External services: none +- Performance claims: none +- Last reviewed: 2026-08-07 \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Models/ApiModels.cs b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Models/ApiModels.cs new file mode 100644 index 0000000..68aae0f --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Models/ApiModels.cs @@ -0,0 +1,23 @@ +namespace NativeAotEssentialsMinimal.Models; + +public sealed record SampleInfo( + string Sample, + string Builder, + string Json); + +public sealed record RuntimeInfo( + string Framework, + string Architecture, + bool DynamicCodeSupported, + bool DynamicCodeCompiled); + +public sealed record EchoRequest( + string? Message); + +public sealed record EchoResponse( + string Message, + int Length); + +public sealed record ApiError( + string Code, + string Message); \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/NativeAotEssentialsMinimal.csproj b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/NativeAotEssentialsMinimal.csproj new file mode 100644 index 0000000..8b84c58 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/NativeAotEssentialsMinimal.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + + true + + enable + enable + + true + + + \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Program.cs b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Program.cs new file mode 100644 index 0000000..6041e32 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Program.cs @@ -0,0 +1,111 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Http.HttpResults; +using NativeAotEssentialsMinimal.Models; +using NativeAotEssentialsMinimal.Serialization; +using NativeAotEssentialsMinimal.Services; + +WebApplicationBuilder builder = + WebApplication.CreateSlimBuilder( + args); + +builder.Services + .ConfigureHttpJsonOptions( + options => + { + options.SerializerOptions + .TypeInfoResolverChain + .Insert( + 0, + AppJsonSerializerContext + .Default); + }); + +builder.Services + .AddSingleton< + ITextTransformer, + TextTransformer>(); + +WebApplication app = + builder.Build(); + +app.MapGet( + "/", + static () => + TypedResults.Ok( + new SampleInfo( + Sample: + "native-aot-essentials", + + Builder: + "CreateSlimBuilder", + + Json: + "source-generated"))); + +app.MapGet( + "/runtime", + static () => + TypedResults.Ok( + new RuntimeInfo( + Framework: + RuntimeInformation + .FrameworkDescription, + + Architecture: + RuntimeInformation + .ProcessArchitecture + .ToString(), + + DynamicCodeSupported: + RuntimeFeature + .IsDynamicCodeSupported, + + DynamicCodeCompiled: + RuntimeFeature + .IsDynamicCodeCompiled))); + +app.MapPost( + "/echo", + static ( + EchoRequest request, + ITextTransformer transformer) => + HandleEcho( + request, + transformer)); + +app.Run(); + +static Results< + Ok, + BadRequest> + HandleEcho( + EchoRequest request, + ITextTransformer transformer) +{ + if (string.IsNullOrWhiteSpace( + request.Message)) + { + return TypedResults.BadRequest( + new ApiError( + Code: + "MESSAGE_REQUIRED", + + Message: + "A non-empty message is required.")); + } + + string message = + transformer.Normalize( + request.Message); + + return TypedResults.Ok( + new EchoResponse( + Message: + message, + + Length: + message.Length)); +} + +public partial class Program; \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Serialization/AppJsonSerializerContext.cs b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Serialization/AppJsonSerializerContext.cs new file mode 100644 index 0000000..8fb9c8a --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Serialization/AppJsonSerializerContext.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using NativeAotEssentialsMinimal.Models; + +namespace NativeAotEssentialsMinimal.Serialization; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = + JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable( + typeof(SampleInfo))] +[JsonSerializable( + typeof(RuntimeInfo))] +[JsonSerializable( + typeof(EchoRequest))] +[JsonSerializable( + typeof(EchoResponse))] +[JsonSerializable( + typeof(ApiError))] +public partial class AppJsonSerializerContext : + JsonSerializerContext +{ +} \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Services/ITextTransformer.cs b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Services/ITextTransformer.cs new file mode 100644 index 0000000..e791e84 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Services/ITextTransformer.cs @@ -0,0 +1,7 @@ +namespace NativeAotEssentialsMinimal.Services; + +public interface ITextTransformer +{ + string Normalize( + string value); +} \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Services/TextTransformer.cs b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Services/TextTransformer.cs new file mode 100644 index 0000000..1e161a4 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/src/NativeAotEssentialsMinimal/Services/TextTransformer.cs @@ -0,0 +1,26 @@ +namespace NativeAotEssentialsMinimal.Services; + +public sealed class TextTransformer : + ITextTransformer +{ + public string Normalize( + string value) + { + ArgumentNullException + .ThrowIfNull( + value); + + string[] words = + value.Split( + ' ', + StringSplitOptions + .RemoveEmptyEntries + | StringSplitOptions + .TrimEntries); + + return string.Join( + ' ', + words) + .ToUpperInvariant(); + } +} \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/tests/NativeAotEssentialsMinimal.Tests/NativeAotEssentialsMinimal.Tests.csproj b/dotnet-8-essentials/core-features-get-started/tests/NativeAotEssentialsMinimal.Tests/NativeAotEssentialsMinimal.Tests.csproj new file mode 100644 index 0000000..38a48a5 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/tests/NativeAotEssentialsMinimal.Tests/NativeAotEssentialsMinimal.Tests.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + enable + enable + true + false + true + Exe + + + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-8-essentials/core-features-get-started/tests/NativeAotEssentialsMinimal.Tests/NativeAotEssentialsTests.cs b/dotnet-8-essentials/core-features-get-started/tests/NativeAotEssentialsMinimal.Tests/NativeAotEssentialsTests.cs new file mode 100644 index 0000000..a0fdba4 --- /dev/null +++ b/dotnet-8-essentials/core-features-get-started/tests/NativeAotEssentialsMinimal.Tests/NativeAotEssentialsTests.cs @@ -0,0 +1,240 @@ +using System.Net; +using System.Net.Http.Json; +using NativeAotEssentialsMinimal.Models; +using NativeAotEssentialsMinimal.Serialization; +using NativeAotEssentialsMinimal.Services; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace NativeAotEssentialsMinimal.Tests; + +public sealed class NativeAotEssentialsTests +{ + [Fact] + public void + Json_context_contains_all_api_types() + { + Assert.NotNull( + AppJsonSerializerContext + .Default + .SampleInfo); + + Assert.NotNull( + AppJsonSerializerContext + .Default + .RuntimeInfo); + + Assert.NotNull( + AppJsonSerializerContext + .Default + .EchoRequest); + + Assert.NotNull( + AppJsonSerializerContext + .Default + .EchoResponse); + + Assert.NotNull( + AppJsonSerializerContext + .Default + .ApiError); + } + + [Fact] + public void + Transformer_normalizes_spacing_and_case() + { + var transformer = + new TextTransformer(); + + string result = + transformer.Normalize( + " hello native aot "); + + Assert.Equal( + "HELLO NATIVE AOT", + result); + } + + [Fact] + public void + Transformer_rejects_null() + { + var transformer = + new TextTransformer(); + + Assert.Throws< + ArgumentNullException>( + () => + transformer.Normalize( + null!)); + } + + [Fact] + public async Task + Root_returns_fixed_sample_metadata() + { + await using var factory = + new WebApplicationFactory< + Program>(); + + HttpClient client = + factory.CreateClient(); + + SampleInfo? response = + await client + .GetFromJsonAsync< + SampleInfo>( + "/", + TestContext.Current + .CancellationToken); + + Assert.NotNull( + response); + + Assert.Equal( + "native-aot-essentials", + response.Sample); + + Assert.Equal( + "CreateSlimBuilder", + response.Builder); + + Assert.Equal( + "source-generated", + response.Json); + } + + [Fact] + public async Task + Runtime_endpoint_returns_process_metadata() + { + await using var factory = + new WebApplicationFactory< + Program>(); + + HttpClient client = + factory.CreateClient(); + + RuntimeInfo? response = + await client + .GetFromJsonAsync< + RuntimeInfo>( + "/runtime", + TestContext.Current + .CancellationToken); + + Assert.NotNull( + response); + + Assert.NotEmpty( + response.Framework); + + Assert.NotEmpty( + response.Architecture); + } + + [Fact] + public async Task + Echo_returns_normalized_typed_response() + { + await using var factory = + new WebApplicationFactory< + Program>(); + + HttpClient client = + factory.CreateClient(); + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/echo", + new EchoRequest( + " hello native aot "), + TestContext.Current + .CancellationToken); + + response + .EnsureSuccessStatusCode(); + + EchoResponse? payload = + await response.Content + .ReadFromJsonAsync< + EchoResponse>( + TestContext.Current + .CancellationToken); + + Assert.NotNull( + payload); + + Assert.Equal( + "HELLO NATIVE AOT", + payload.Message); + + Assert.Equal( + 16, + payload.Length); + } + + [Fact] + public async Task + Echo_rejects_blank_message() + { + await using var factory = + new WebApplicationFactory< + Program>(); + + HttpClient client = + factory.CreateClient(); + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/echo", + new EchoRequest( + " "), + TestContext.Current + .CancellationToken); + + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + ApiError? error = + await response.Content + .ReadFromJsonAsync< + ApiError>( + TestContext.Current + .CancellationToken); + + Assert.NotNull( + error); + + Assert.Equal( + "MESSAGE_REQUIRED", + error.Code); + + Assert.Equal( + "A non-empty message is required.", + error.Message); + } + + [Fact] + public async Task + Unknown_route_returns_not_found() + { + await using var factory = + new WebApplicationFactory< + Program>(); + + HttpClient client = + factory.CreateClient(); + + HttpResponseMessage response = + await client.GetAsync( + "/this-route-does-not-exist", + TestContext.Current + .CancellationToken); + + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + } +} \ No newline at end of file