diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6b8676..659912a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ section is the GitHub release's notes. - Analyzers: CMTK0009 warns at `FirstOrNone`, `SingleOrNone` or `FirstOrError` on a query given a `Func` variable or a method group, which binds to the `IEnumerable` overload and reads every row; a lambda at the call or an `Expression` stays silent ([A condition that reads every row](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#a-condition-that-reads-every-row)). - Docs: the guides, the package READMEs, the changelog and the API of every run-time package as one site with a menu and search, built from the latest release ([caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/)). - 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)). - EF Core: queries, keys and round trips are tested on a real SQL Server, and the README says what differs there from memory: text compared ignoring case, Guids sorted by their last group, `decimal(18,2)` by default, a `TimeSpan` under a day, `Greatest` from compatibility level 160 ([On SQL Server](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server)). ### Changed diff --git a/README.md b/README.md index 277bb0a6..04d5db6f 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ It shows the latest release. | 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/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 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 d19dfb31..74d0aea7 100644 --- a/test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs +++ b/test/CodoMetis.TypeKit.Oracles.Tests/OracleCatalogue.cs @@ -49,6 +49,7 @@ internal static partial class OracleCatalogue "docs/result.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 0c39bc1f..bbae78ba 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -4742,5 +4742,175 @@ "heldBy": [ "DestructuringTests" ] + }, + { + "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/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