From c7d016b50b9cf45c66d3b9415b9655155dec482e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:17:40 +0100 Subject: [PATCH 1/2] docs: a walkthrough of one orders service, run by CI from the packages docs/walkthrough.md follows one minimal-API orders service from request to response and says what changes at each boundary. The service is the smoke test's fifth consumer, walkthrough: built outside the repository against the packed Generators, EF Core, ASP.NET Core and Serilog packages, on SQLite in memory. Its answers, its log line, the SQL EF Core logs and the OpenAPI document are checked against the page's comments, read by WalkthroughPage. WalkthroughTests compiles the same sources and runs the service in memory, so it runs on every pull request's test leg and holds the page's claims (W01-W12). ReadmeSampleTests and docs-query find walkthrough/ regions in the consumer's sources, and a region must live in its page's sources. Seeded: an excerpt edited in the page alone fails ReadmeSampleTests; the mask changed in the code but not in the comment fails WalkthroughTests and the smoke test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 1 + design/native-aot.md | 3 +- docs/value-objects.md | 4 +- docs/walkthrough.md | 369 ++++++++++++++++++ .../ReadmeSampleTests.cs | 38 +- .../ReadmeSamples.cs | 22 +- .../OracleCatalogue.cs | 1 + .../CodoMetis.TypeKit.Samples.csproj | 11 + .../Docs/WalkthroughTests.cs | 110 ++++++ test/oracles/claims.json | 170 ++++++++ test/oracles/surface-waivers.json | 1 - .../Consumers/walkthrough/OrderEndpoints.cs | 60 +++ .../Consumers/walkthrough/Orders.cs | 88 +++++ .../Consumers/walkthrough/Program.cs | 63 +++ .../Consumers/walkthrough/Tour.cs | 90 +++++ tools/ConsumerSmokeTest/Program.cs | 112 +++++- tools/ConsumerSmokeTest/WalkthroughPage.cs | 94 +++++ tools/DocsQuery/Corpus.cs | 29 +- 19 files changed, 1235 insertions(+), 32 deletions(-) create mode 100644 docs/walkthrough.md create mode 100644 test/CodoMetis.TypeKit.Samples/Docs/WalkthroughTests.cs create mode 100644 tools/ConsumerSmokeTest/Consumers/walkthrough/OrderEndpoints.cs create mode 100644 tools/ConsumerSmokeTest/Consumers/walkthrough/Orders.cs create mode 100644 tools/ConsumerSmokeTest/Consumers/walkthrough/Program.cs create mode 100644 tools/ConsumerSmokeTest/Consumers/walkthrough/Tour.cs create mode 100644 tools/ConsumerSmokeTest/WalkthroughPage.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d33373ac..43b54a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ section is the GitHub release's notes. - `Fault.Cause`: `Fault.Of(reason, detail, exception)` keeps the exception underneath for your log; equality and `ToString()` leave it out, and `ToHttpResult()` hands it to the host as `IFaultFeature.Cause` and `FaultProblem.Cause`, never into the problem ([What failed underneath](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-failed-underneath)). - Testing: every assertion works on a `Task` or `ValueTask` of what it checks, `await service.FindAsync(id).ShouldBeSome()`; a task that throws fails the test with its own exception, and an assertion left without `await` is reported (CMTK0003) ([CodoMetis.TypeKit.Testing](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Testing/README.md)). - Analyzers: a validated value object without `Create` gets a code fix, **Add Create**, that writes a `Create` accepting every value, ready for your rules ([Add Create](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#add-create)). +- Docs: a walkthrough follows one orders service from request to response, saying what changes at each boundary; the service is a sample that CI builds from the packages and runs, and the answers the page states are checked against it ([Walkthrough](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/walkthrough.md)). ### Changed diff --git a/README.md b/README.md index 14d6850e..a31d5994 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ READMEs is compiled, and the outputs it states are tested. | Page | Covers | |---|---| +| [Walkthrough](docs/walkthrough.md) | One orders service from request to response: value objects bound, validated, stored, queried, patched, logged and described, and what changes at each boundary | | [Value objects](docs/value-objects.md) | Declare one, pick a struct or a class, validate input, design its errors, pick the right factory, write your own members, generic code | | [Closed sets](docs/closed-sets.md) | Smart enums that carry data: declare one, handle every member with `Match`, send it as JSON, store it as a text column, read it from configuration, use one as an error type, and when an enum is still the right choice | | [Option](docs/option.md) | A value or nothing: create one, get the value out, combine options, convert at the boundary, and every public member | diff --git a/design/native-aot.md b/design/native-aot.md index 78bdf5fa..36da59f0 100644 --- a/design/native-aot.md +++ b/design/native-aot.md @@ -39,6 +39,7 @@ host, EF Core through `dotnet ef dbcontext optimize --precompile-queries --nativ unchanged (measured with `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`, which leaves the `System.Globalization.Invariant` switch unset). `AsText()` asks once whether a decomposed letter composes, and throws `PlatformNotSupportedException` where it does not; `InvariantGlobalizationTests` - runs the test assembly again in that mode. The smoke test's consumers do not call `AsText()`. + runs the test assembly again in that mode. The smoke test's `aot` consumers do not call `AsText()`; its + `walkthrough` consumer does, on the JIT, without that mode. - **Third-party packages** report their own warnings (NodaTime.Serialization.SystemTextJson 1.4.0, EF Core). The smoke test asserts only that none names a CodoMetis package or a woven value object. diff --git a/docs/value-objects.md b/docs/value-objects.md index 76f8cc6f..d31036e4 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -8,7 +8,9 @@ This page helps you design them: pick a struct or a class, write the validation, error carries, and call the right factory. The [Generators README](../src/CodoMetis.TypeKit.Generators/README.md) lists everything that is generated, member by member, and every build error. The interfaces themselves are in the -[base README](../src/CodoMetis.TypeKit/README.md#value-objects). +[base README](../src/CodoMetis.TypeKit/README.md#value-objects). To see value objects cross every +boundary of one service, from the request to the database, the log and back, read the +[Walkthrough](walkthrough.md). Reference `CodoMetis.TypeKit.Generators` in the project that declares value objects. The examples import `CodoMetis.TypeKit` and `CodoMetis.TypeKit.ValueObjects`. diff --git a/docs/walkthrough.md b/docs/walkthrough.md new file mode 100644 index 00000000..5d4e2304 --- /dev/null +++ b/docs/walkthrough.md @@ -0,0 +1,369 @@ +# Walkthrough: one orders service + +This page follows one small orders service from the request to the database and back. Each guide +covers one boundary. Here you see one e-mail address cross all of them, and at each step what +happens to it, or that nothing does. + +The service is a minimal API on SQLite. It uses the generators, the EF Core package, the ASP.NET Core +package and the Serilog package. Its code is a sample application. CI runs it on every change, and +builds it from the packed packages too, so every answer below is one it really gives: +[Orders.cs](../tools/ConsumerSmokeTest/Consumers/walkthrough/Orders.cs), +[OrderEndpoints.cs](../tools/ConsumerSmokeTest/Consumers/walkthrough/OrderEndpoints.cs), +[Program.cs](../tools/ConsumerSmokeTest/Consumers/walkthrough/Program.cs), and the requests in +[Tour.cs](../tools/ConsumerSmokeTest/Consumers/walkthrough/Tour.cs). + +| Step | What happens to the e-mail address | +|---|---| +| [Declare the values](#declare-the-values) | `Create` trims it, composes it and checks it, once | +| [Store them](#store-them) | it becomes a plain `TEXT` column | +| [Place an order](#place-an-order) | JSON reads it through `Create` and writes it back as a string. The log masks it | +| [Reject what doesn't fit](#reject-what-doesnt-fit) | a rejected value gets a 400 that never quotes it | +| [Find an order](#find-an-order) | nothing: an `Option` becomes a 404 | +| [Query on `.Value`](#query-on-value) | nothing: `.Value` is the column in SQL | +| [Change an order](#change-an-order) | a `Patch` reads each field through its own `Create` | +| [Read the OpenAPI document](#read-the-openapi-document) | it is described as the string it is | + +## Declare the values + +```csharp +public readonly partial record struct OrderId : IValue; + +public enum EmailFault { Blank, Hidden, NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.AsText() // trimmed, composed, no hidden characters + .MapError(fault => fault == TextFault.Blank ? EmailFault.Blank : EmailFault.Hidden) + .Ensure(text => text.IndexOf('@') > 0, EmailFault.NoAt) + .Map(text => new Email(text)); + + public override string ToString() => $"{Value[0]}***{Value[Value.IndexOf('@')..]}"; // what a log shows +} + +public enum QuantityFault { NotPositive } + +public readonly partial record struct Quantity : IValidatedValue +{ + public static Result Create(int value) => + value > 0 ? new Quantity(value) : Result.Error(QuantityFault.NotPositive); +} + +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Placed = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} +``` + +These are the service's whole domain: + +- **`OrderId`** wraps a `Guid`. `OrderId.New()` makes a version 7 one. +- **`Email`** is validated. Its `Create` runs the text guard `AsText()`, which trims the address, + composes it to one spelling and rejects hidden characters. Then it checks for an `@`. Every way an + `Email` gets created runs this `Create`: JSON, a route or query value, `TryFrom`. +- **`Email.ToString()`** masks the address. The generators keep a `ToString()` you write, and every + log line prints it. +- **`Quantity`** rejects zero and less. +- **`OrderStatus`** is a closed set: an enum whose members are instances, written as their names. + +The [Value objects](value-objects.md), [Written text](text.md) and [Closed sets](closed-sets.md) +guides cover each kind. + +## Store them + +```csharp +public sealed class Order +{ + public required OrderId Id { get; init; } + public required Email Email { get; set; } + public required Quantity Quantity { get; set; } + public required OrderStatus Status { get; set; } +} + +public sealed class OrdersDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} +``` + +The entity uses the value objects as its properties, with nothing to register per type. +`Program.cs` sets up the host. `UseTypeKit()` maps each value object to a column of the type inside +it, and `EnsureCreated()` shows the table that comes of it. `MapOrders()` maps the endpoints the next +sections show. + +```csharp +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Serilog; +using Walkthrough; +``` + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(options => options.AddTypeKit()); +builder.Services.AddFaultResponses(fault => fault switch +{ + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.AlreadyShipped or OrderFault.Cancelled => StatusCodes.Status409Conflict, + OrderFault.Cleared => StatusCodes.Status422UnprocessableEntity, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), +}); +builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.RespectRequiredConstructorParameters = true); + +var connection = new SqliteConnection("Data Source=:memory:"); // the database lives as long as this connection is open +connection.Open(); +builder.Services.AddDbContext(options => options.UseSqlite(connection).UseTypeKit() + .LogTo(Console.WriteLine, [RelationalEventId.CommandExecuted])); + +builder.Services.AddSingleton(new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}") + .CreateLogger()); +``` + +```csharp +var app = builder.Build(); +app.UseStatusCodePages(); +app.MapOpenApi(); +app.MapOrders(); + +using (var scope = app.Services.CreateScope()) +{ + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + // CREATE TABLE "Orders" ( + // "Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY, + // "Email" TEXT NOT NULL, + // "Quantity" INTEGER NOT NULL, + // "Status" TEXT NOT NULL + // ); +} +``` + +What changes here: a value object becomes its underlying type. The id is a `TEXT` key, as a `Guid` is +on SQLite, and the quantity an `INTEGER`. The status is its member's name. Reading a row back skips +`Create`, because the service wrote that value itself ([EF Core](ef-core.md)). + +**Good to know** + +- **The database lives in one open connection.** SQLite in memory keeps a database as long as its + connection is open, so the service opens one and keeps it. Your own service passes a connection + string instead. +- **`LogTo` prints the SQL** that EF Core runs, which is how this page shows it. +- **`RespectRequiredConstructorParameters`** makes a body that leaves out `email` a 400. Without it, + the `Email` would be `default`, an address that never went through `Create` + ([ASP.NET Core](aspnet-core.md#what-a-refused-value-answers)). + +## Place an order + +```csharp +public sealed record PlaceOrder(Email Email, Quantity Quantity); + +public sealed record OrderView(OrderId Id, Email Email, Quantity Quantity, OrderStatus Status) +{ + public static OrderView Of(Order order) => new(order.Id, order.Email, order.Quantity, order.Status); +} + +public enum OrderFault { NotFound, AlreadyShipped, Cancelled, Cleared } +``` + +```csharp +app.MapPost("/orders", async (PlaceOrder request, OrdersDb db, Serilog.ILogger log) => +{ + var order = new Order { Id = OrderId.New(), Email = request.Email, Quantity = request.Quantity, Status = OrderStatus.Placed }; + db.Orders.Add(order); + await db.SaveChangesAsync(); + + var view = OrderView.Of(order); + log.Information("Placed {@Order}", view); // Placed {"Id": "…", "Email": "a***@example.com", "Quantity": "2", "Status": "Placed", "$type": "OrderView"} + return TypedResults.Created($"/orders/{order.Id}", view); +}); +``` + +The requests are in [Tour.cs](../tools/ConsumerSmokeTest/Consumers/walkthrough/Tour.cs). `Send` +sends one and returns an `Answer`: its status and its body. The comment after each request is what it +gets back, with `…` for an id. The client sends the address with spaces around it: + +```csharp +Answer placed = await Send("POST", "/orders", """{"email":" ann@example.com ","quantity":2}"""); // 201 {"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"} +OrderId id = JsonSerializer.Deserialize(placed.Body, JsonSerializerOptions.Web)!.Id; +``` + +Here the address crosses three boundaries: + +- **On the way in**, the JSON converter calls `Create`. So the handler receives `ann@example.com`, + trimmed, and never sees the spaces. +- **On the way out**, the JSON is the underlying type's: `"email":"ann@example.com"`, a plain + string, and the closed set is its name ([JSON](json.md)). +- **In the log**, the address is masked. Serilog destructures `{@Order}` by its properties, and + `Destructure.WithTypeKit()` logs each value object as what its `ToString()` prints. Without it, + Serilog would read `Value` and log the address ([Logging and secrets](logging-and-secrets.md)). + +The client reads the answer back into the same types. Its id is an `OrderId` again. + +## Reject what doesn't fit + +```csharp +Answer noAt = await Send("POST", "/orders", """{"email":"ann","quantity":2}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} +Answer noEmail = await Send("POST", "/orders", """{"quantity":2}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} +Answer notAnId = await Send("GET", "/orders/42"); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} +Answer noneAtAll = await Send("GET", "/orders?minimum=0"); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} +``` + +Each of these is a 400 with problem details, and none of them quotes what the client sent: + +- **A body** that `Create` rejects throws a `JsonException` that names `Email` and `NoAt`, never the + text. The handler never runs. +- **A body without `email`** is rejected because of `RespectRequiredConstructorParameters`. +- **A route value** that isn't an `OrderId`, and **a query value** that `Create` rejects, fail the + generated `TryParse`. + +`UseStatusCodePages()` gives these empty 400s their problem body. The body is ASP.NET's own and +names neither the field nor the rule. ASP.NET logs the reason. + +## Find an order + +```csharp +app.MapGet("/orders/{id}", async (OrderId id, OrdersDb db) => + (await db.Orders.FirstOrNoneAsync(order => order.Id == id)) // an Option: the order, or none + .ToResult(OrderFault.NotFound) + .Map(OrderView.Of) + .ToHttpResult()) + .WithoutFaults(OrderFault.AlreadyShipped, OrderFault.Cancelled, OrderFault.Cleared); // finding one reports only NotFound +``` + +```csharp +Answer found = await Send("GET", $"/orders/{id}"); // 200 {"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"} +Answer missing = await Send("GET", $"/orders/{OrderId.New()}"); // 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"fault":"NotFound","traceId":"…"} +``` + +`FirstOrNoneAsync` asks the database for one row and returns an `Option`, so there is no `null` to +forget. `ToResult` turns a missing order into the error `NotFound`, and `ToHttpResult()` answers it +with the status the setup maps it to, as problem details. The `fault` member names the error, so a +client can tell a missing order from any other 404 ([Result](result.md), +[ASP.NET Core](aspnet-core.md#a-result-as-a-response)). + +`WithoutFaults` lists the errors this endpoint never reports. The OpenAPI document then shows only +the 404 for it, where it would otherwise list every status `OrderFault` maps to. + +Nothing changes for the id here. The route value becomes an `OrderId` through `TryParse`, and the +query compares it with the key column. + +**Good to know** + +- **ASP0020 is a false alarm here.** The service declares its value objects in the web project, so + ASP.NET's route analyzer reads `OrderId` before the generators make it parsable. `OrderEndpoints.cs` + suppresses the warning. Value objects in a domain project of their own need nothing + ([ASP.NET Core](aspnet-core.md#bind-value-objects-in-a-minimal-api)). + +## Query on `.Value` + +```csharp +app.MapGet("/orders", async (Quantity minimum, OrdersDb db) => + await db.Orders.Where(order => order.Quantity.Value >= minimum.Value) // WHERE "o"."Quantity" >= @minimum_Value + .Select(order => OrderView.Of(order)) + .ToListAsync()); +``` + +```csharp +Answer atLeastTwo = await Send("GET", "/orders?minimum=2"); // 200 [{"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"}] +Answer atLeastFive = await Send("GET", "/orders?minimum=5"); // 200 [] +``` + +`minimum` comes from the query string, through `Create`. In the query, `order.Quantity.Value` is the +`Quantity` column itself, so the comparison runs in the database. `.Value` works the same way in memory +and in SQL ([EF Core](ef-core.md)). + +## Change an order + +```csharp +public sealed record OrderPatch +{ + public Patch Quantity { get; init; } + public Patch Status { get; init; } +} + +public static class OrderPatching +{ + public static Result Apply(this Order order, OrderPatch patch) + { + if (order.Status == OrderStatus.Shipped) return Result.Error(OrderFault.AlreadyShipped); + if (order.Status == OrderStatus.Cancelled) return Result.Error(OrderFault.Cancelled); + if (patch.Quantity.State == PatchState.Null || patch.Status.State == PatchState.Null) return Result.Error(OrderFault.Cleared); // neither field can be cleared + + patch.Quantity.Tap(quantity => order.Quantity = quantity); + patch.Status.Tap(status => order.Status = status); + return order; + } +} +``` + +```csharp +app.MapPatch("/orders/{id}", async (OrderId id, OrderPatch patch, OrdersDb db) => +{ + var changed = (await db.Orders.FirstOrNoneAsync(order => order.Id == id)) + .ToResult(OrderFault.NotFound) + .Bind(order => order.Apply(patch)); + await db.SaveChangesAsync(); + return changed.Map(OrderView.Of).ToHttpResult(); +}); +``` + +```csharp +Answer three = await Send("PATCH", $"/orders/{id}", """{"quantity":3}"""); // 200 {"id":"…","email":"ann@example.com","quantity":3,"status":"Placed"} +Answer zero = await Send("PATCH", $"/orders/{id}", """{"quantity":0}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} +Answer cleared = await Send("PATCH", $"/orders/{id}", """{"quantity":null}"""); // 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Unprocessable Entity","status":422,"fault":"Cleared","traceId":"…"} +Answer shipped = await Send("PATCH", $"/orders/{id}", """{"status":"Shipped"}"""); // 200 {"id":"…","email":"ann@example.com","quantity":3,"status":"Shipped"} +Answer tooLate = await Send("PATCH", $"/orders/{id}", """{"quantity":4}"""); // 409 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"fault":"AlreadyShipped","traceId":"…"} +``` + +A `Patch` tells apart a field the request left out, a field set to `null`, and a new value. Each +field reads its value through its own `Create`, so `{"quantity":0}` is a 400 like any other body. +`Apply` decides what a `null` means. Here neither field can be cleared, so a clear gets the error +`Cleared`. A shipped order changes no more, and gets `AlreadyShipped` +([Merge patches](merge-patches.md)). + +`Bind` runs `Apply` only on an order that was found, so one `ToHttpResult()` answers every error. + +## Read the OpenAPI document + +```csharp +JsonNode document = JsonNode.Parse(await http.GetStringAsync("/openapi/v1.json"))!; +JsonNode schemas = document["components"]!["schemas"]!; +JsonNode find = document["paths"]!["/orders/{id}"]!["get"]!; + +string idParameter = find["parameters"]![0]!["schema"]!.ToJsonString(); // {"type":"string","format":"uuid"} +string findAnswers = string.Join(",", find["responses"]!.AsObject().Select(answer => answer.Key)); // 200,404 +string emailSchema = schemas["Email"]!.ToJsonString(); // {"type":"string"} +string statusSchema = schemas["OrderStatus"]!.ToJsonString(); // {"enum":["Placed","Shipped","Cancelled"],"type":"string"} +string patchType = schemas["OrderPatch"]!["properties"]!["quantity"]!["type"]!.ToJsonString(); // ["null","integer","string"] +``` + +`AddTypeKit()` describes every value object as the schema of the type inside it: + +- **The route's `id`** is a `uuid` string, as a `Guid` parameter would be. +- **The errors** are listed beside the success: `GET /orders/{id}` answers 200 or 404. +- **`Email`** is a string in a body, and keeps a component of its own. +- **The closed set** is a string enum of its names. +- **A patch field** is its underlying type's schema, and may also be `null`. The `string` is + ASP.NET's own: a minimal API reads a number from a string too. + +So a client generated from the document sends and receives plain strings and numbers +([ASP.NET Core](aspnet-core.md#see-value-objects-in-the-openapi-document)). + +## What changes where + +| Boundary | The e-mail address there | +|---|---| +| `Create` | trimmed and composed, or rejected | +| JSON, in a request or a response | the string itself | +| A route or query value | read by the generated `TryParse`, which calls `Create` | +| The database | a `TEXT` column, read back without `Create` | +| A LINQ query | `.Value` is the column | +| A rejected request | a 400 that names neither the value nor the rule | +| A log line | what `ToString()` prints: masked | +| The OpenAPI document | a string | diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs index 82b5727d..676e5d26 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSampleTests.cs @@ -2,8 +2,9 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// /// Every C# block in the READMEs and the topic pages under docs/ is compiled code: it equals, -/// after indentation, exactly one sample region in test/CodoMetis.TypeKit.Samples, and every -/// region there is shown in the page its name starts with. A README edit that is not mirrored in the samples fails here, and a sample that +/// after indentation, exactly one sample region in test/CodoMetis.TypeKit.Samples, or in the sources a +/// page names for itself (: the walkthrough's sample application), +/// and every region is shown in the page its name starts with. A README edit that is not mirrored in the samples fails here, and a sample that /// stops compiling fails the build. /// /// @@ -44,6 +45,7 @@ public void The_docs_pages_are_discovered() => [InlineData("README.md", "README")] [InlineData("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md", "CodoMetis.TypeKit.EntityFrameworkCore")] [InlineData("docs/ef-core.md", "ef-core")] + [InlineData("docs/walkthrough.md", "walkthrough")] public void A_page_s_samples_are_named_after_it(string page, string prefix) => ReadmeSamples.Prefix(page).ShouldBe(prefix); /// @@ -92,6 +94,36 @@ public void The_csharp_blocks_and_the_samples_are_discovered() Samples.Value.Count.ShouldBeGreaterThanOrEqualTo(30); } + /// + /// The walkthrough's sample application is found: a directory renamed would leave its page's blocks matching + /// nothing, which the theories report, but a page that lost its blocks with it would pass. + /// + [Fact] + public void Each_page_s_own_sources_hold_its_samples() + { + foreach (var (prefix, directory) in ReadmeSamples.RegionSources) + { + Directory.Exists(Path.Combine(Repository.Root, directory)).ShouldBeTrue($"{directory}, where the '{prefix}' samples live, does not exist."); + Samples.Value.Count(sample => sample.ReadmePrefix == prefix).ShouldBeGreaterThanOrEqualTo(10, $"{directory} holds too few '{prefix}/' samples."); + } + } + + /// + /// A page's region lives in the sources its prefix names, and nowhere else: a walkthrough region in the samples + /// project would be compiled where the packed packages never build it, and a samples region among the + /// walkthrough's sources would be shipped to its consumer. + /// + [Theory] + [MemberData(nameof(SampleNames))] + public void A_sample_lives_in_the_sources_of_its_page(string name) + { + var sample = Samples.Value.Single(candidate => candidate.Name == name); + var expected = ReadmeSamples.SourcesOf(sample.ReadmePrefix); + + sample.File.ShouldStartWith(expected + "/", Case.Sensitive, + $"'{name}' ({sample.Location}) belongs to the page '{sample.ReadmePrefix}', whose samples live in {expected}."); + } + [Theory] [MemberData(nameof(CompiledBlocks))] public void A_csharp_block_in_a_README_is_a_compiled_sample(string location) @@ -102,7 +134,7 @@ public void A_csharp_block_in_a_README_is_a_compiled_sample(string location) if (sample is null) { throw new ShouldAssertException( - $"{location} is not compiled: no sample in {ReadmeSamples.SamplesProject} holds this code. Mirror the change in the sample region, " + + $"{location} is not compiled: no sample in {ReadmeSamples.SourcesOf(ReadmeSamples.Prefix(block.Readme))} holds this code. Mirror the change in the sample region, " + $"or mark a block that cannot compile on purpose with above its fence.{Environment.NewLine}" + Closest(block.Code, Samples.Value.Select(candidate => (candidate.Name + " at " + candidate.Location, candidate.Code)), "sample", "README")); } diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs index ab771ab8..f43d92b9 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ReadmeSamples.cs @@ -42,6 +42,19 @@ internal static partial class ReadmeSamples public const string DocsDirectory = "docs"; + /// + /// The pages whose regions live outside the samples project, each with the directory that holds them. The + /// walkthrough's are the sources of the smoke test's walkthrough consumer: a sample application that + /// the packed packages build and run, and whose answers the page states. + /// + public static readonly IReadOnlyDictionary RegionSources = new Dictionary(StringComparer.Ordinal) + { + ["walkthrough"] = "tools/ConsumerSmokeTest/Consumers/walkthrough", + }; + + /// Where the regions of the page with this prefix live: its own sources, or the samples project. + public static string SourcesOf(string prefix) => RegionSources.GetValueOrDefault(prefix, SamplesProject); + /// /// Every page whose C# is compiled: the root README, every shipping package's, discovered from /// src/, and every topic page in docs/ (). @@ -93,12 +106,13 @@ public static IReadOnlyList DocumentationLinks(string markdown) public static IReadOnlyList CodeBlocks() => [.. Readmes().SelectMany(readme => CodeBlocks(readme, File.ReadAllText(Path.Combine(Repository.Root, readme))))]; + /// Every region of the samples project and of each page's own sources (). public static IReadOnlyList Regions() { - var project = Path.Combine(Repository.Root, SamplesProject); - - var regions = Directory.EnumerateFiles(project, "*.cs", SearchOption.AllDirectories) - .Where(path => !Path.GetRelativePath(project, path).Split(Path.DirectorySeparatorChar).Any(part => part is "bin" or "obj")) + var regions = new[] { SamplesProject }.Concat(RegionSources.Values) + .Select(directory => Path.Combine(Repository.Root, directory)) + .SelectMany(directory => Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories) + .Where(path => !Path.GetRelativePath(directory, path).Split(Path.DirectorySeparatorChar).Any(part => part is "bin" or "obj"))) .Order(StringComparer.Ordinal) .SelectMany(path => Regions(Path.GetRelativePath(Repository.Root, path).Replace('\\', '/'), File.ReadAllText(path))) .ToArray(); diff --git a/test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs b/test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs index 9e499a07..a7b7cddc 100644 --- a/test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs +++ b/test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs @@ -47,6 +47,7 @@ internal static partial class OracleCatalogue "docs/merge-patches.md", "docs/ef-core.md", "docs/type-constraints.md", + "docs/walkthrough.md", "src/CodoMetis.TypeKit.AspNetCore/README.md", "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md", "src/CodoMetis.TypeKit.Generators/README.md", diff --git a/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj b/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj index 67288916..a79cceb7 100644 --- a/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj +++ b/test/CodoMetis.TypeKit.Samples/CodoMetis.TypeKit.Samples.csproj @@ -31,6 +31,17 @@ + + + + + + diff --git a/test/CodoMetis.TypeKit.Samples/Docs/WalkthroughTests.cs b/test/CodoMetis.TypeKit.Samples/Docs/WalkthroughTests.cs new file mode 100644 index 00000000..8cc3ac5f --- /dev/null +++ b/test/CodoMetis.TypeKit.Samples/Docs/WalkthroughTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; +using CodoMetis.TypeKit.AspNetCore; +using ConsumerSmokeTest; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Walkthrough; + +namespace ReadmeSamples.Docs; + +/// +/// The walkthrough's orders service, run in memory from the project references: every answer, log line and SQL +/// statement docs/walkthrough.md states in a comment is what the service gives. The consumer smoke test +/// runs the same service from the packed packages (tools/ConsumerSmokeTest, its walkthrough +/// consumer, whose sources this project compiles too); this runs it on every pull request's test leg. +/// +/// +/// The expected text is read from the page by , the smoke test's own reader, compiled +/// here too: never written into this test, so a changed answer is corrected in the page's comment. +/// +[Collection(nameof(Serilog.ConsoleOutput))] +public sealed class WalkthroughTests +{ + private static readonly string Page = SampleOutputs.Readme(WalkthroughPage.Path); + + [Fact] + public async Task The_service_answers_what_the_page_states() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var sql = new ConcurrentQueue(); + + // The setup Program.cs has, but for the host's address and the SQL, which is kept here rather than printed. + // The log is written to the console as there, with the console captured: the collection runs alone. + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddOpenApi(options => options.AddTypeKit()); + builder.Services.AddFaultResponses(fault => fault switch + { + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.AlreadyShipped or OrderFault.Cancelled => StatusCodes.Status409Conflict, + OrderFault.Cleared => StatusCodes.Status422UnprocessableEntity, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), + }); + builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.RespectRequiredConstructorParameters = true); + builder.Services.AddDbContext(options => options.UseSqlite(connection).UseTypeKit() + .LogTo(sql.Enqueue, [RelationalEventId.CommandExecuted])); + builder.Services.AddSingleton(new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}") + .CreateLogger()); + + await using var app = builder.Build(); + app.UseStatusCodePages(); + app.MapOpenApi(); + app.MapOrders(); + using (var scope = app.Services.CreateScope()) scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + await app.StartAsync(TestContext.Current.CancellationToken); + + var reported = new List<(string Name, string Value)>(); + var console = new StringWriter(); + var original = Console.Out; + Console.SetOut(console); + try + { + await Tour.RunAsync(app.GetTestClient(), (name, value) => reported.Add((name, value))); + } + finally + { + Console.SetOut(original); + } + await app.StopAsync(TestContext.Current.CancellationToken); + + var problems = new List(); + foreach (var (name, value) in reported) + { + var stated = WalkthroughPage.Stated(Page, WalkthroughPage.Answer(name)); + if (!WalkthroughPage.Is(stated, value)) problems.Add($"the page states '{stated.Single()}' for {name}, but it is '{value}'"); + } + + WalkthroughPage.Declarations(Page).Except(reported.Select(answer => answer.Name)) + .ShouldBeEmpty("The page states these answers, which the tour never reports."); + + var logged = console.ToString().ReplaceLineEndings("\n").Split('\n').Where(line => line.StartsWith("[INF] ", StringComparison.Ordinal)).ToArray(); + Within(problems, "log.Information(\"Placed", logged, "the log"); + + var statements = sql.SelectMany(message => message.Split('\n')).ToArray(); + Within(problems, "order.Quantity.Value >= minimum.Value", statements, "the SQL"); + Within(problems, "Database.EnsureCreated()", statements, "the SQL"); + + logged.ShouldNotContain(line => line.Contains("ann@example.com", StringComparison.Ordinal), "The e-mail address is masked in every log line."); + problems.ShouldBeEmpty(); + } + + private static void Within(List problems, string fragment, IReadOnlyList lines, string what) + { + var stated = WalkthroughPage.Stated(Page, fragment); + if (!WalkthroughPage.Within(stated, lines)) + problems.Add($"the page states '{string.Join(" / ", stated)}' for '{fragment}', which {what} does not hold: {string.Join(" | ", lines)}"); + } +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 83cb6a4d..99c5096c 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2200,5 +2200,175 @@ "heldBy": [ "NotWireTypeTests" ] + }, + { + "id": "W01-answers-are-real", + "source": "docs/walkthrough.md#walkthrough-one-orders-service", + "quote": "CI runs it on every change, and builds it from the packed packages too, so every answer below is one it really gives", + "claim": "CI runs it on every change, and builds it from the packed packages too, so every answer below is one it really gives", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit", + "TypeKitHttpResultExtensions.ToHttpResult", + "TypeKitDbContextOptionsBuilderExtensions.UseTypeKit", + "TypeKitSerilogExtensions.WithTypeKit" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W02-a-column-of-the-underlying-type", + "source": "docs/walkthrough.md#store-them", + "quote": "The id is a `TEXT` key, as a `Guid` is on SQLite, and the quantity an `INTEGER`. The status is its member's name.", + "claim": "The id is a `TEXT` key, as a `Guid` is on SQLite, and the quantity an `INTEGER`. The status is its member's name.", + "oracle": "claim", + "surfaces": [ + "TypeKitDbContextOptionsBuilderExtensions.UseTypeKit", + "ClosedSetConverter.*", + "ValueObjectConverter.*" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W03-json-reads-through-create", + "source": "docs/walkthrough.md#place-an-order", + "quote": "So the handler receives `ann@example.com`, trimmed, and never sees the spaces.", + "claim": "So the handler receives `ann@example.com`, trimmed, and never sees the spaces.", + "oracle": "claim", + "surfaces": [ + "GeneratedJson.*", + "StringTextExtensions.*", + "IValidatedValue.Create" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W04-json-writes-the-string", + "source": "docs/walkthrough.md#place-an-order", + "quote": "the JSON is the underlying type's: `\"email\":\"ann@example.com\"`, a plain string, and the closed set is its name", + "claim": "the JSON is the underlying type's: `\"email\":\"ann@example.com\"`, a plain string, and the closed set is its name", + "oracle": "claim", + "surfaces": [ + "GeneratedJson.*", + "IClosedSet.*" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W05-log-masks", + "source": "docs/walkthrough.md#place-an-order", + "quote": "`Destructure.WithTypeKit()` logs each value object as what its `ToString()` prints.", + "claim": "`Destructure.WithTypeKit()` logs each value object as what its `ToString()` prints.", + "oracle": "claim", + "surfaces": [ + "TypeKitSerilogExtensions.WithTypeKit" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W06-rejections-never-quote", + "source": "docs/walkthrough.md#reject-what-doesnt-fit", + "quote": "Each of these is a 400 with problem details, and none of them quotes what the client sent", + "claim": "Each of these is a 400 with problem details, and none of them quotes what the client sent", + "oracle": "claim", + "surfaces": [ + "GeneratedJson.*", + "GeneratedParsing.*" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W07-none-is-a-404", + "source": "docs/walkthrough.md#find-an-order", + "quote": "`ToResult` turns a missing order into the error `NotFound`, and `ToHttpResult()` answers it with the status the setup maps it to, as problem details.", + "claim": "`ToResult` turns a missing order into the error `NotFound`, and `ToHttpResult()` answers it with the status the setup maps it to, as problem details.", + "oracle": "claim", + "surfaces": [ + "TypeKitQueryableExtensions.*", + "TypeKitHttpResultExtensions.ToHttpResult" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W08-left-out-errors-leave-the-document", + "source": "docs/walkthrough.md#find-an-order", + "quote": "The OpenAPI document then shows only the 404 for it, where it would otherwise list every status `OrderFault` maps to.", + "claim": "The OpenAPI document then shows only the 404 for it, where it would otherwise list every status `OrderFault` maps to.", + "oracle": "claim", + "surfaces": [ + "TypeKitWithoutFaultsEndpointConventionBuilderExtensions.*", + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W09-value-is-the-column", + "source": "docs/walkthrough.md#query-on-value", + "quote": "In the query, `order.Quantity.Value` is the `Quantity` column itself, so the comparison runs in the database.", + "claim": "In the query, `order.Quantity.Value` is the `Quantity` column itself, so the comparison runs in the database.", + "oracle": "claim", + "surfaces": [ + "IValueObject.Value", + "TypeKitDbContextOptionsBuilderExtensions.UseTypeKit" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W10-patch-reads-through-create", + "source": "docs/walkthrough.md#change-an-order", + "quote": "Each field reads its value through its own `Create`, so `{\"quantity\":0}` is a 400 like any other body.", + "claim": "Each field reads its value through its own `Create`, so `{\"quantity\":0}` is a 400 like any other body.", + "oracle": "claim", + "surfaces": [ + "PatchJsonConverterFactory.*", + "GeneratedJson.*" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W11-document-describes-the-underlying-type", + "source": "docs/walkthrough.md#read-the-openapi-document", + "quote": "`AddTypeKit()` describes every value object as the schema of the type inside it", + "claim": "`AddTypeKit()` describes every value object as the schema of the type inside it", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit" + ], + "heldBy": [ + "WalkthroughTests" + ] + }, + { + "id": "W12-closed-set-is-a-string-enum", + "source": "docs/walkthrough.md#read-the-openapi-document", + "quote": "**The closed set** is a string enum of its names.", + "claim": "**The closed set** is a string enum of its names.", + "oracle": "claim", + "surfaces": [ + "TypeKitOpenApiOptionsExtensions.AddTypeKit", + "IClosedSet.*" + ], + "heldBy": [ + "WalkthroughTests" + ] } ] diff --git a/test/oracles/surface-waivers.json b/test/oracles/surface-waivers.json index 7b89a2d0..5b884c8b 100644 --- a/test/oracles/surface-waivers.json +++ b/test/oracles/surface-waivers.json @@ -22,7 +22,6 @@ "SequenceOptionExtensions.Choose": "unread: the document that promises its behaviour has not been read for claims yet (#98); the core tests hold it meanwhile", "ValueTypeOptionExtensions.OrNull": "unread: the document that promises its behaviour has not been read for claims yet (#98); the core tests hold it meanwhile", "GuidValueExtensions.New": "unread: the document that promises its behaviour has not been read for claims yet (#98); the core tests hold it meanwhile", - "TypeKitSerilogExtensions.WithTypeKit": "unread: the document that promises its behaviour has not been read for claims yet (#98); the core tests hold it meanwhile", "TypeKitClosedSetHttpResultExtensions.ToHttpResult": "unread: the document that promises its behaviour has not been read for claims yet (#98); the core tests hold it meanwhile", "RequireCustomInitializationAttribute.*": "unread: the document that promises its behaviour has not been read for claims yet (#98); the core tests hold it meanwhile" } diff --git a/tools/ConsumerSmokeTest/Consumers/walkthrough/OrderEndpoints.cs b/tools/ConsumerSmokeTest/Consumers/walkthrough/OrderEndpoints.cs new file mode 100644 index 00000000..9b3eb015 --- /dev/null +++ b/tools/ConsumerSmokeTest/Consumers/walkthrough/OrderEndpoints.cs @@ -0,0 +1,60 @@ +// The orders service's endpoints. docs/walkthrough.md shows each region; test/CodoMetis.TypeKit.Samples +// compiles this file too, for WalkthroughTests. +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; + +namespace Walkthrough; + +// The value objects are declared beside the endpoints, so ASP.NET's route analyzer reads OrderId before the +// generators make it parsable (docs/aspnet-core.md). In a domain project of their own they would need no pragma. +#pragma warning disable ASP0020 +public static class OrderEndpoints +{ + public static void MapOrders(this IEndpointRouteBuilder app) + { + // sample: walkthrough/place + app.MapPost("/orders", async (PlaceOrder request, OrdersDb db, Serilog.ILogger log) => + { + var order = new Order { Id = OrderId.New(), Email = request.Email, Quantity = request.Quantity, Status = OrderStatus.Placed }; + db.Orders.Add(order); + await db.SaveChangesAsync(); + + var view = OrderView.Of(order); + log.Information("Placed {@Order}", view); // Placed {"Id": "…", "Email": "a***@example.com", "Quantity": "2", "Status": "Placed", "$type": "OrderView"} + return TypedResults.Created($"/orders/{order.Id}", view); + }); + // end sample + + // sample: walkthrough/find + app.MapGet("/orders/{id}", async (OrderId id, OrdersDb db) => + (await db.Orders.FirstOrNoneAsync(order => order.Id == id)) // an Option: the order, or none + .ToResult(OrderFault.NotFound) + .Map(OrderView.Of) + .ToHttpResult()) + .WithoutFaults(OrderFault.AlreadyShipped, OrderFault.Cancelled, OrderFault.Cleared); // finding one reports only NotFound + // end sample + + // sample: walkthrough/list + app.MapGet("/orders", async (Quantity minimum, OrdersDb db) => + await db.Orders.Where(order => order.Quantity.Value >= minimum.Value) // WHERE "o"."Quantity" >= @minimum_Value + .Select(order => OrderView.Of(order)) + .ToListAsync()); + // end sample + + // sample: walkthrough/change + app.MapPatch("/orders/{id}", async (OrderId id, OrderPatch patch, OrdersDb db) => + { + var changed = (await db.Orders.FirstOrNoneAsync(order => order.Id == id)) + .ToResult(OrderFault.NotFound) + .Bind(order => order.Apply(patch)); + await db.SaveChangesAsync(); + return changed.Map(OrderView.Of).ToHttpResult(); + }); + // end sample + } +} +#pragma warning restore ASP0020 diff --git a/tools/ConsumerSmokeTest/Consumers/walkthrough/Orders.cs b/tools/ConsumerSmokeTest/Consumers/walkthrough/Orders.cs new file mode 100644 index 00000000..2a489b72 --- /dev/null +++ b/tools/ConsumerSmokeTest/Consumers/walkthrough/Orders.cs @@ -0,0 +1,88 @@ +// The orders service's domain: its value objects, what it stores, and what it exchanges. docs/walkthrough.md +// shows each region; test/CodoMetis.TypeKit.Samples compiles this file too, for WalkthroughTests. +using CodoMetis.TypeKit; +using CodoMetis.TypeKit.ClosedSets; +using CodoMetis.TypeKit.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace Walkthrough; + +// sample: walkthrough/values +public readonly partial record struct OrderId : IValue; + +public enum EmailFault { Blank, Hidden, NoAt } + +public readonly partial record struct Email : IValidatedValue +{ + public static Result Create(string value) => + value.AsText() // trimmed, composed, no hidden characters + .MapError(fault => fault == TextFault.Blank ? EmailFault.Blank : EmailFault.Hidden) + .Ensure(text => text.IndexOf('@') > 0, EmailFault.NoAt) + .Map(text => new Email(text)); + + public override string ToString() => $"{Value[0]}***{Value[Value.IndexOf('@')..]}"; // what a log shows +} + +public enum QuantityFault { NotPositive } + +public readonly partial record struct Quantity : IValidatedValue +{ + public static Result Create(int value) => + value > 0 ? new Quantity(value) : Result.Error(QuantityFault.NotPositive); +} + +public sealed partial class OrderStatus : IClosedSet +{ + public static readonly OrderStatus Placed = new(); + public static readonly OrderStatus Shipped = new(); + public static readonly OrderStatus Cancelled = new(); +} +// end sample + +// sample: walkthrough/storage +public sealed class Order +{ + public required OrderId Id { get; init; } + public required Email Email { get; set; } + public required Quantity Quantity { get; set; } + public required OrderStatus Status { get; set; } +} + +public sealed class OrdersDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); +} +// end sample + +// sample: walkthrough/messages +public sealed record PlaceOrder(Email Email, Quantity Quantity); + +public sealed record OrderView(OrderId Id, Email Email, Quantity Quantity, OrderStatus Status) +{ + public static OrderView Of(Order order) => new(order.Id, order.Email, order.Quantity, order.Status); +} + +public enum OrderFault { NotFound, AlreadyShipped, Cancelled, Cleared } +// end sample + +// sample: walkthrough/patch +public sealed record OrderPatch +{ + public Patch Quantity { get; init; } + public Patch Status { get; init; } +} + +public static class OrderPatching +{ + public static Result Apply(this Order order, OrderPatch patch) + { + if (order.Status == OrderStatus.Shipped) return Result.Error(OrderFault.AlreadyShipped); + if (order.Status == OrderStatus.Cancelled) return Result.Error(OrderFault.Cancelled); + if (patch.Quantity.State == PatchState.Null || patch.Status.State == PatchState.Null) return Result.Error(OrderFault.Cleared); // neither field can be cleared + + patch.Quantity.Tap(quantity => order.Quantity = quantity); + patch.Status.Tap(status => order.Status = status); + return order; + } +} +// end sample diff --git a/tools/ConsumerSmokeTest/Consumers/walkthrough/Program.cs b/tools/ConsumerSmokeTest/Consumers/walkthrough/Program.cs new file mode 100644 index 00000000..85b0c4f5 --- /dev/null +++ b/tools/ConsumerSmokeTest/Consumers/walkthrough/Program.cs @@ -0,0 +1,63 @@ +// The orders service docs/walkthrough.md follows: its host, started on a free port, sent the page's requests by +// Tour.cs. Each answer is printed as name=value, the SQL EF Core runs and the log lines as they are written, and +// the smoke test compares them with what the page states. +// sample: walkthrough/usings +using CodoMetis.TypeKit.AspNetCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Serilog; +using Walkthrough; +// end sample + +// sample: walkthrough/setup +var builder = WebApplication.CreateBuilder(args); +// end sample +builder.WebHost.UseUrls("http://127.0.0.1:0"); +builder.Logging.ClearProviders(); +// sample: walkthrough/setup + +builder.Services.AddOpenApi(options => options.AddTypeKit()); +builder.Services.AddFaultResponses(fault => fault switch +{ + OrderFault.NotFound => StatusCodes.Status404NotFound, + OrderFault.AlreadyShipped or OrderFault.Cancelled => StatusCodes.Status409Conflict, + OrderFault.Cleared => StatusCodes.Status422UnprocessableEntity, + _ => throw new ArgumentOutOfRangeException(nameof(fault)), +}); +builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.RespectRequiredConstructorParameters = true); + +var connection = new SqliteConnection("Data Source=:memory:"); // the database lives as long as this connection is open +connection.Open(); +builder.Services.AddDbContext(options => options.UseSqlite(connection).UseTypeKit() + .LogTo(Console.WriteLine, [RelationalEventId.CommandExecuted])); + +builder.Services.AddSingleton(new LoggerConfiguration() + .Destructure.WithTypeKit() + .WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}") + .CreateLogger()); +// end sample + +// sample: walkthrough/app +var app = builder.Build(); +app.UseStatusCodePages(); +app.MapOpenApi(); +app.MapOrders(); + +using (var scope = app.Services.CreateScope()) +{ + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + // CREATE TABLE "Orders" ( + // "Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY, + // "Email" TEXT NOT NULL, + // "Quantity" INTEGER NOT NULL, + // "Status" TEXT NOT NULL + // ); +} +// end sample + +await app.StartAsync(); +using var http = new HttpClient { BaseAddress = new Uri(app.Urls.First()) }; +await Tour.RunAsync(http, (name, value) => Console.WriteLine($"{name}={value}")); +await app.StopAsync(); +connection.Dispose(); diff --git a/tools/ConsumerSmokeTest/Consumers/walkthrough/Tour.cs b/tools/ConsumerSmokeTest/Consumers/walkthrough/Tour.cs new file mode 100644 index 00000000..bf89dba3 --- /dev/null +++ b/tools/ConsumerSmokeTest/Consumers/walkthrough/Tour.cs @@ -0,0 +1,90 @@ +// The requests docs/walkthrough.md follows, sent to a running orders service. Each answer the page states in a +// comment is reported under the name of the variable that holds it, and the smoke test and WalkthroughTests +// compare the two (test/CodoMetis.TypeKit.Samples compiles this file too). +using System.Text; +using CodoMetis.TypeKit; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Walkthrough; + +/// An HTTP answer as the page writes it: the status, a space, the body. +public sealed record Answer(int Status, string Body) +{ + public override string ToString() => $"{Status} {Body}"; +} + +public static class Tour +{ + public static async Task RunAsync(HttpClient http, Action report) + { + async Task Send(string method, string path, string? json = null) + { + using var request = new HttpRequestMessage(new HttpMethod(method), path); + if (json is not null) request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await http.SendAsync(request); + return new Answer((int)response.StatusCode, await response.Content.ReadAsStringAsync()); + } + + // sample: walkthrough/place-request + Answer placed = await Send("POST", "/orders", """{"email":" ann@example.com ","quantity":2}"""); // 201 {"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"} + OrderId id = JsonSerializer.Deserialize(placed.Body, JsonSerializerOptions.Web)!.Id; + // end sample + report(nameof(placed), placed.ToString()); + + // sample: walkthrough/refused + Answer noAt = await Send("POST", "/orders", """{"email":"ann","quantity":2}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} + Answer noEmail = await Send("POST", "/orders", """{"quantity":2}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} + Answer notAnId = await Send("GET", "/orders/42"); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} + Answer noneAtAll = await Send("GET", "/orders?minimum=0"); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} + // end sample + report(nameof(noAt), noAt.ToString()); + report(nameof(noEmail), noEmail.ToString()); + report(nameof(notAnId), notAnId.ToString()); + report(nameof(noneAtAll), noneAtAll.ToString()); + + // sample: walkthrough/find-request + Answer found = await Send("GET", $"/orders/{id}"); // 200 {"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"} + Answer missing = await Send("GET", $"/orders/{OrderId.New()}"); // 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"fault":"NotFound","traceId":"…"} + // end sample + report(nameof(found), found.ToString()); + report(nameof(missing), missing.ToString()); + + // sample: walkthrough/list-request + Answer atLeastTwo = await Send("GET", "/orders?minimum=2"); // 200 [{"id":"…","email":"ann@example.com","quantity":2,"status":"Placed"}] + Answer atLeastFive = await Send("GET", "/orders?minimum=5"); // 200 [] + // end sample + report(nameof(atLeastTwo), atLeastTwo.ToString()); + report(nameof(atLeastFive), atLeastFive.ToString()); + + // sample: walkthrough/change-request + Answer three = await Send("PATCH", $"/orders/{id}", """{"quantity":3}"""); // 200 {"id":"…","email":"ann@example.com","quantity":3,"status":"Placed"} + Answer zero = await Send("PATCH", $"/orders/{id}", """{"quantity":0}"""); // 400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"traceId":"…"} + Answer cleared = await Send("PATCH", $"/orders/{id}", """{"quantity":null}"""); // 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Unprocessable Entity","status":422,"fault":"Cleared","traceId":"…"} + Answer shipped = await Send("PATCH", $"/orders/{id}", """{"status":"Shipped"}"""); // 200 {"id":"…","email":"ann@example.com","quantity":3,"status":"Shipped"} + Answer tooLate = await Send("PATCH", $"/orders/{id}", """{"quantity":4}"""); // 409 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"fault":"AlreadyShipped","traceId":"…"} + // end sample + report(nameof(three), three.ToString()); + report(nameof(zero), zero.ToString()); + report(nameof(cleared), cleared.ToString()); + report(nameof(shipped), shipped.ToString()); + report(nameof(tooLate), tooLate.ToString()); + + // sample: walkthrough/document + JsonNode document = JsonNode.Parse(await http.GetStringAsync("/openapi/v1.json"))!; + JsonNode schemas = document["components"]!["schemas"]!; + JsonNode find = document["paths"]!["/orders/{id}"]!["get"]!; + + string idParameter = find["parameters"]![0]!["schema"]!.ToJsonString(); // {"type":"string","format":"uuid"} + string findAnswers = string.Join(",", find["responses"]!.AsObject().Select(answer => answer.Key)); // 200,404 + string emailSchema = schemas["Email"]!.ToJsonString(); // {"type":"string"} + string statusSchema = schemas["OrderStatus"]!.ToJsonString(); // {"enum":["Placed","Shipped","Cancelled"],"type":"string"} + string patchType = schemas["OrderPatch"]!["properties"]!["quantity"]!["type"]!.ToJsonString(); // ["null","integer","string"] + // end sample + report(nameof(idParameter), idParameter); + report(nameof(findAnswers), findAnswers); + report(nameof(emailSchema), emailSchema); + report(nameof(statusSchema), statusSchema); + report(nameof(patchType), patchType); + } +} diff --git a/tools/ConsumerSmokeTest/Program.cs b/tools/ConsumerSmokeTest/Program.cs index df978a2b..6365952a 100644 --- a/tools/ConsumerSmokeTest/Program.cs +++ b/tools/ConsumerSmokeTest/Program.cs @@ -12,7 +12,7 @@ // consumer builds), compiles real code against them, runs it, and asserts on what it prints, on the // diagnostics the build reports, and on the resolved package graph. Never on the exit code alone. // -// Four consumers, their sources under Consumers/: +// Five consumers, their sources under Consumers/: // core references only CodoMetis.TypeKit: Option and Result work, no Metalama arrives, and // CMTK0001 and CMTK0002 fire (design/analyzers.md). // layered a domain library references CodoMetis.TypeKit.Generators, and an app reaches everything @@ -25,13 +25,17 @@ // aot the same ground published with Native AOT (design/native-aot.md): no trim or AOT warning // from a package or from the code woven into the consumer, and the native binary does // what the JIT build does. +// walkthrough the orders service docs/walkthrough.md follows, on all four packages a web service uses: every +// answer, log line and SQL statement the page states in a comment is what it gives, read from the page +// (WalkthroughPage.cs). // // Native AOT needs the platform linker: clang (and zlib on Linux), which the workflows install, or // MSVC's C++ build tools on Windows. // -// Usage: dotnet tools/ConsumerSmokeTest/bin/Release/net10.0/consumer-smoke-test.dll [feed-directory] +// Usage: dotnet tools/ConsumerSmokeTest/bin/Release/net10.0/consumer-smoke-test.dll [feed-directory] [--only ] // With no argument the packages are packed fresh. Pass a directory of existing .nupkg files (the -// release workflow passes its pack output) to test exactly the artifacts being shipped. +// release workflow passes its pack output) to test exactly the artifacts being shipped. --only runs one +// consumer, for a local check: CI runs them all. using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; @@ -43,19 +47,22 @@ Console.Error.WriteLine($"consumer-smoke-test: this dll was built from other sources than the checkout holds ({stale}). Run `{SourceFingerprint.RebuildCommand}`."); return SourceFingerprint.StaleExitCode; } -if (args.Length > 1) +var at = Array.IndexOf(args, "--only"); +var only = at >= 0 && at + 1 < args.Length ? args[at + 1] : null; +string[] positional = at >= 0 ? [.. args[..at], .. args[Math.Min(at + 2, args.Length)..]] : args; +if (positional.Length > 1 || (at >= 0 && only is not ("core" or "layered" or "host" or "aot" or "walkthrough"))) { - Console.Error.WriteLine("Usage: consumer-smoke-test.dll [feed-directory]"); + Console.Error.WriteLine("Usage: consumer-smoke-test.dll [feed-directory] [--only core|layered|host|aot|walkthrough]"); return 2; } -return new ConsumerSmokeTest.Smoke(root!, args.FirstOrDefault()).Run(); +return new ConsumerSmokeTest.Smoke(root!, positional.FirstOrDefault(), only).Run(); namespace ConsumerSmokeTest { /// A command that failed where it had to succeed, or the reverse: the run stops, as the script's set -e did. internal sealed class Stop : Exception; - internal sealed partial class Smoke(string repoRoot, string? givenFeed) + internal sealed partial class Smoke(string repoRoot, string? givenFeed, string? only) { private readonly DirectoryInfo _created = Directory.CreateTempSubdirectory("consumer-smoke-test-"); private string Work => _created.FullName; @@ -184,10 +191,12 @@ private void Main() """); File.Copy(Path.Combine(repoRoot, "global.json"), Path.Combine(Work, "global.json")); - Core(); - Layered(); - Host(); - Aot(); + // host builds on layered's Domain project, so it brings layered along. + if (only is null or "core") Core(); + if (only is null or "layered" or "host") Layered(); + if (only is null or "host") Host(); + if (only is null or "aot") Aot(); + if (only is null or "walkthrough") Walkthrough(); } // ── core: the base package alone ─────────────────────────────────────────────────────────────── @@ -491,6 +500,87 @@ private void AotPublishAndRun(string name) File.AppendAllText(Path.Combine(Work, "aot.out"), Run(native) + "\n"); } + // ── walkthrough: the orders service of docs/walkthrough.md ───────────────────────────────────── + // + // One web project on the four packages a service uses, as a reader would set it up: what the page shows is + // what it compiles. It prints each answer as name=value, the SQL EF Core runs and its log lines, and every + // expectation is read from the page's comments (WalkthroughPage), never written here: a changed answer fails + // until the page says it. ReadmeSampleTests holds the page's blocks to these sources, so an edit to a stated + // answer is an edit here too, which selects the pack lane; WalkthroughTests runs the same service in memory + // on every test leg. + + private void Walkthrough() + { + Console.WriteLine("==> walkthrough: the orders service docs/walkthrough.md follows"); + NewProject("web", "walkthrough", "CodoMetis.TypeKit.Generators", "CodoMetis.TypeKit.EntityFrameworkCore", "CodoMetis.TypeKit.AspNetCore", "CodoMetis.TypeKit.Serilog"); + Run("dotnet", "add", "package", "Microsoft.EntityFrameworkCore.Sqlite", "--version", CentralVersion("Microsoft.EntityFrameworkCore.Relational")); + Run("dotnet", "add", "package", "Microsoft.AspNetCore.OpenApi", "--version", CentralVersion("Microsoft.AspNetCore.OpenApi")); + Run("dotnet", "add", "package", "Serilog.Sinks.Console", "--version", CentralVersion("Serilog.Sinks.Console")); + foreach (var source in (string[])["Orders.cs", "OrderEndpoints.cs", "Tour.cs", "Program.cs"]) Write(source, $"walkthrough/{source}"); + + Run("dotnet", "build"); + ListPackages("walkthrough.packages"); + Save("walkthrough.out", Run("dotnet", "run", "--no-build", "--no-launch-profile")); + Print("walkthrough.out"); + + Console.WriteLine("==> walkthrough: asserting against docs/walkthrough.md"); + AssertContains("> CodoMetis.TypeKit.Generators ", "walkthrough.packages", "the generators resolved from the feed"); + AssertContains("> CodoMetis.TypeKit.Serilog ", "walkthrough.packages", "the Serilog package resolved from the feed"); + AssertVersions("walkthrough.packages"); + + var page = File.ReadAllText(Path.Combine(repoRoot, WalkthroughPage.Path)); + var lines = SplitLines(Read("walkthrough.out")).ToList(); + var answers = lines.Select(line => AnswerLine().Match(line)).Where(match => match.Success) + .ToDictionary(match => match.Groups["name"].Value, match => match.Groups["value"].Value, StringComparer.Ordinal); + + foreach (var name in WalkthroughPage.Declarations(page).Union(answers.Keys, StringComparer.Ordinal)) + { + var value = answers.GetValueOrDefault(name); + AssertStated(page, WalkthroughPage.Answer(name), stated => value is not null && WalkthroughPage.Is(stated, value), + value is null ? $"{name} is stated, and the service never answered it" : $"{name} is what the page states"); + } + var logged = lines.Where(line => line.StartsWith("[INF] ", StringComparison.Ordinal)).ToList(); + AssertStated(page, "log.Information(\"Placed", stated => WalkthroughPage.Within(stated, logged), "the log line is what the page states: the address masked"); + AssertStated(page, "order.Quantity.Value >= minimum.Value", stated => WalkthroughPage.Within(stated, lines), ".Value in the query is the column in the SQL EF Core logs"); + AssertStated(page, "Database.EnsureCreated()", stated => WalkthroughPage.Within(stated, lines), "each value object is a column of its underlying type"); + if (logged.Count == 0 || logged.Any(line => line.Contains("ann@example.com", StringComparison.Ordinal))) + { + Console.WriteLine(" FAIL: the e-mail address is masked in every log line, and there is one"); + foreach (var line in logged) Console.WriteLine(" " + line); + _failed = true; + } + else + { + Console.WriteLine(" ok: the e-mail address is masked in every log line"); + } + } + + // What the page states for the fragment holds; a page that no longer states it fails too. + private void AssertStated(string page, string fragment, Func, bool> holds, string message) + { + IReadOnlyList stated; + try + { + stated = WalkthroughPage.Stated(page, fragment); + } + catch (InvalidOperationException e) + { + Console.WriteLine($" FAIL: {message} ({e.Message})"); + _failed = true; + return; + } + if (holds(stated)) + { + Console.WriteLine($" ok: {message}"); + return; + } + Console.WriteLine($" FAIL: {message} (the page states '{string.Join(" / ", stated)}' for '{fragment.Trim()}')"); + _failed = true; + } + + [GeneratedRegex(@"^(?[a-z]\w*)=(?.*)$")] + private static partial Regex AnswerLine(); + // ── the steps ────────────────────────────────────────────────────────────────────────────────── // Creates a project from a template under the work directory and installs packages into it. No diff --git a/tools/ConsumerSmokeTest/WalkthroughPage.cs b/tools/ConsumerSmokeTest/WalkthroughPage.cs new file mode 100644 index 00000000..190cf75c --- /dev/null +++ b/tools/ConsumerSmokeTest/WalkthroughPage.cs @@ -0,0 +1,94 @@ +using System.Text.RegularExpressions; + +namespace ConsumerSmokeTest; + +/// +/// What docs/walkthrough.md states in its C# comments: the answers, the log line and the SQL of the orders service +/// its blocks show. The smoke test's walkthrough consumer is that service, and test/CodoMetis.TypeKit.Samples compiles +/// this file for WalkthroughTests, which runs the service in memory; both compare what it gives with this. +/// +/// +/// A statement is the comment after the one line of C# whose code holds a fragment, or the comment lines right below +/// that line. An ellipsis (U+2026) in it stands for any text, such as an order's id or a trace id. The expected text +/// is read from the page, never written into a check, so a changed answer is corrected in the page's comment: and +/// ReadmeSampleTests holds the page's blocks to the consumer's sources, so the line read here is the line it runs. +/// +internal static partial class WalkthroughPage +{ + public const string Path = "docs/walkthrough.md"; + + private const char Ellipsis = (char)0x2026; + + /// The statement for , one entry per line; throws when no single line holds it, or it states nothing. + public static IReadOnlyList Stated(string page, string fragment) + { + var lines = CSharpLines(page).ToArray(); + var matches = Enumerable.Range(0, lines.Length).Where(i => CodeOf(lines[i]).Contains(fragment, StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1) throw new InvalidOperationException($"{Path} should have one line of C# holding '{fragment}', but has {matches.Length}."); + + if (CommentOf(lines[matches[0]]) is { } comment) return [comment]; + + var below = lines.Skip(matches[0] + 1).TakeWhile(line => line.TrimStart().StartsWith("//", StringComparison.Ordinal)) + .Select(line => line.TrimStart()[2..].Trim()).ToArray(); + return below.Length > 0 ? below : throw new InvalidOperationException($"{Path} states nothing for '{fragment}'."); + } + + /// The answers the page states: each Answer or string declared on a line with a comment, by the variable's name. + public static IReadOnlyList Declarations(string page) => + [ + .. CSharpLines(page).Where(line => CommentOf(line) is not null) + .Select(line => Declaration().Match(CodeOf(line))) + .Where(match => match.Success) + .Select(match => match.Groups["name"].Value) + ]; + + /// The fragment that finds the statement of a declared answer: its name and the assignment. + public static string Answer(string name) => $" {name} = "; + + /// Whether is the one-line statement, whole. + public static bool Is(IReadOnlyList stated, string value) => stated.Count == 1 && Pattern(stated[0], whole: true).IsMatch(value); + + /// Whether the stated lines are consecutive lines of , each ending in its statement. + public static bool Within(IReadOnlyList stated, IReadOnlyList lines) + { + var patterns = stated.Select(line => Pattern(line, whole: false)).ToArray(); + return Enumerable.Range(0, Math.Max(0, lines.Count - patterns.Length + 1)) + .Any(start => patterns.Select((pattern, i) => pattern.IsMatch(lines[start + i].Trim())).All(matched => matched)); + } + + private static Regex Pattern(string statement, bool whole) => + new((whole ? "^" : "") + string.Join(".*?", statement.Split(Ellipsis).Select(Regex.Escape)) + "$", RegexOptions.CultureInvariant); + + private static IEnumerable CSharpLines(string page) + { + var inCSharp = false; + foreach (var line in page.ReplaceLineEndings("\n").Split('\n')) + { + if (line.TrimStart().StartsWith("```", StringComparison.Ordinal)) inCSharp = !inCSharp && line.TrimStart()[3..].Trim() is "csharp" or "cs"; + else if (inCSharp) yield return line; + } + } + + private static string CodeOf(string line) => (CommentStart(line) is var start and >= 0 ? line[..start] : line).Trim(); + + // The comment after code on the same line; a line that is only a comment has none. + private static string? CommentOf(string line) => + CommentStart(line) is var start and >= 0 && line[..start].Trim().Length > 0 ? line[(start + 2)..].Trim() : null; + + // Where a line's // comment starts, outside any string literal. + private static int CommentStart(string line) + { + var inString = false; + for (var i = 0; i < line.Length - 1; i++) + { + if (inString && line[i] == '\\') i++; + else if (line[i] == '"') inString = !inString; + else if (!inString && line[i] == '/' && line[i + 1] == '/') return i; + } + + return -1; + } + + [GeneratedRegex(@"^(?:Answer|string) (?\w+) = ")] + private static partial Regex Declaration(); +} diff --git a/tools/DocsQuery/Corpus.cs b/tools/DocsQuery/Corpus.cs index 01bd3b1e..d294e68d 100644 --- a/tools/DocsQuery/Corpus.cs +++ b/tools/DocsQuery/Corpus.cs @@ -95,17 +95,24 @@ void Add(string relativeDirectory, string pattern, SearchOption option) .Order(StringComparer.Ordinal)]; } - /// The samples project's C# files, whose // sample: regions compile the pages' blocks. - public static IReadOnlyList Samples(string root) - { - var directory = Path.Combine(root, SamplesProject); - if (!Directory.Exists(directory)) return []; - - return [.. Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories) - .Select(path => Relative(root, path)) - .Where(path => !InBuildOutput(path)) - .Order(StringComparer.Ordinal)]; - } + /// + /// Where a page keeps its regions outside the samples project: the walkthrough's sample application, the smoke + /// test's consumer that the packed packages build. ReadmeSamples.RegionSources in the conventions tests is the + /// same list. + /// + public static readonly string[] PageSources = ["tools/ConsumerSmokeTest/Consumers/walkthrough"]; + + /// The C# files whose // sample: regions compile the pages' blocks: the samples project's and each page's own sources. + public static IReadOnlyList Samples(string root) => + [ + .. new[] { SamplesProject }.Concat(PageSources) + .Select(relative => Path.Combine(root, relative)) + .Where(Directory.Exists) + .SelectMany(directory => Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories)) + .Select(path => Relative(root, path)) + .Where(path => !InBuildOutput(path)) + .Order(StringComparer.Ordinal) + ]; /// /// The fingerprint of an index: the schema version, the tool's own version (the hash of its From af3fffcc435fb03a66c4d860440a8b02a4786ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:51:31 +0100 Subject: [PATCH 2/2] docs: the docs site lists the walkthrough and checks its links to the sample site/toc.yml lists the walkthrough first among the guides, as the README's Documentation table does (DocsSiteTests). The sample's sources join the site's resources, so DocFX checks the page's links to them and the template rewrites them to GitHub: without them the build reported five InvalidFileLink warnings, with them none. Co-Authored-By: Claude Opus 5.5 --- site/docfx.json | 2 +- site/toc.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/site/docfx.json b/site/docfx.json index 037bc1dc..0dbd56ab 100644 --- a/site/docfx.json +++ b/site/docfx.json @@ -24,7 +24,7 @@ { "src": "..", "files": [ "README.md", "CHANGELOG.md", "docs/*.md", "src/*/README.md" ] } ], "resource": [ - { "src": "..", "dest": "_repository", "files": [ "design/**", "CONTRIBUTING.md", "SECURITY.md", "AGENTS.md", "LICENSE" ] } + { "src": "..", "dest": "_repository", "files": [ "design/**", "CONTRIBUTING.md", "SECURITY.md", "AGENTS.md", "LICENSE", "tools/ConsumerSmokeTest/Consumers/walkthrough/*.cs" ] } ], "output": "obj/_site", "template": [ "default", "modern", "template" ], diff --git a/site/toc.yml b/site/toc.yml index b3f9a3f8..e18bfb5f 100644 --- a/site/toc.yml +++ b/site/toc.yml @@ -2,6 +2,8 @@ href: ../README.md - name: Guides items: + - name: Walkthrough + href: ../docs/walkthrough.md - name: Value objects href: ../docs/value-objects.md - name: Closed sets