diff --git a/astro.config.mjs b/astro.config.mjs index 0dc9a27..38e68b9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -13,7 +13,7 @@ const sidebars = { { label: 'Start here', items: ['minimalapi/getting-started', 'minimalapi/guides/run-locally'] }, { label: 'Guides', - items: ['minimalapi/guides/add-an-endpoint', 'minimalapi/guides/test-your-changes', 'minimalapi/guides/database', 'minimalapi/guides/secure-the-api', 'minimalapi/guides/ship-the-container', 'minimalapi/guides/set-up-ci', 'minimalapi/guides/work-with-a-coding-agent'], + items: ['minimalapi/guides/make-it-yours', 'minimalapi/guides/add-an-endpoint', 'minimalapi/guides/test-your-changes', 'minimalapi/guides/database', 'minimalapi/guides/secure-the-api', 'minimalapi/guides/ship-the-container', 'minimalapi/guides/set-up-ci', 'minimalapi/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'minimalapi/reference' } }] }, { @@ -25,7 +25,7 @@ const sidebars = { { label: 'Start here', items: ['webapi/getting-started', 'webapi/guides/run-locally'] }, { label: 'Guides', - items: ['webapi/guides/add-an-endpoint', 'webapi/guides/test-your-changes', 'webapi/guides/database', 'webapi/guides/secure-the-api', 'webapi/guides/ship-the-container', 'webapi/guides/set-up-ci', 'webapi/guides/work-with-a-coding-agent'], + items: ['webapi/guides/make-it-yours', 'webapi/guides/add-an-endpoint', 'webapi/guides/test-your-changes', 'webapi/guides/database', 'webapi/guides/secure-the-api', 'webapi/guides/ship-the-container', 'webapi/guides/set-up-ci', 'webapi/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'webapi/reference' } }] }, { @@ -37,7 +37,7 @@ const sidebars = { { label: 'Start here', items: ['graphql/getting-started', 'graphql/guides/run-locally'] }, { label: 'Guides', - items: ['graphql/guides/add-a-field', 'graphql/guides/test-your-changes', 'graphql/guides/database', 'graphql/guides/secure-the-api', 'graphql/guides/ship-the-container', 'graphql/guides/set-up-ci', 'graphql/guides/work-with-a-coding-agent'], + items: ['graphql/guides/make-it-yours', 'graphql/guides/add-a-field', 'graphql/guides/test-your-changes', 'graphql/guides/database', 'graphql/guides/secure-the-api', 'graphql/guides/ship-the-container', 'graphql/guides/set-up-ci', 'graphql/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'graphql/reference' } }] }, { @@ -49,7 +49,7 @@ const sidebars = { { label: 'Start here', items: ['mcp/getting-started', 'mcp/guides/run-locally'] }, { label: 'Guides', - items: ['mcp/guides/connect-a-client', 'mcp/guides/add-a-tool', 'mcp/guides/test-your-changes', 'mcp/guides/database', 'mcp/guides/secure-the-server', 'mcp/guides/ship-the-container', 'mcp/guides/set-up-ci', 'mcp/guides/work-with-a-coding-agent'], + items: ['mcp/guides/make-it-yours', 'mcp/guides/connect-a-client', 'mcp/guides/add-a-tool', 'mcp/guides/test-your-changes', 'mcp/guides/database', 'mcp/guides/secure-the-server', 'mcp/guides/ship-the-container', 'mcp/guides/set-up-ci', 'mcp/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'mcp/reference' } }] }, { @@ -61,7 +61,7 @@ const sidebars = { { label: 'Start here', items: ['lambda/getting-started', 'lambda/guides/run-locally'] }, { label: 'Guides', - items: ['lambda/guides/add-a-function', 'lambda/guides/test-your-changes', 'lambda/guides/database', 'lambda/guides/deploy-to-aws', 'lambda/guides/secure-the-function', 'lambda/guides/set-up-ci', 'lambda/guides/work-with-a-coding-agent'], + items: ['lambda/guides/make-it-yours', 'lambda/guides/add-a-function', 'lambda/guides/test-your-changes', 'lambda/guides/database', 'lambda/guides/deploy-to-aws', 'lambda/guides/secure-the-function', 'lambda/guides/set-up-ci', 'lambda/guides/work-with-a-coding-agent'], }, { label: 'Reference', items: [{ autogenerate: { directory: 'lambda/reference' } }] }, { diff --git a/src/content/docs/graphql/getting-started.mdx b/src/content/docs/graphql/getting-started.mdx index 2036553..5416646 100644 --- a/src/content/docs/graphql/getting-started.mdx +++ b/src/content/docs/graphql/getting-started.mdx @@ -111,6 +111,7 @@ Hit a snag? Check [Troubleshooting](/graphql/reference/troubleshooting/). + diff --git a/src/content/docs/graphql/guides/make-it-yours.mdx b/src/content/docs/graphql/guides/make-it-yours.mdx new file mode 100644 index 0000000..bc3a819 --- /dev/null +++ b/src/content/docs/graphql/guides/make-it-yours.mdx @@ -0,0 +1,504 @@ +--- +title: "Make it yours" +description: "Add your own entity end to end, then clear out the sample movies, authors and reviews without dropping below the 95% coverage gate." +sidebar: + order: 2 +--- + +import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; + +Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, the error filter and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. + +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with a `cinemas` query to list and search them and a `createCinema` mutation to add one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 77 passing tests and 99.1% line coverage. + + + +The code below assumes you named the solution `WorksOnMyMachine.Movies`. Swap in your own name wherever you see it. + + + +1. **Add the entity and use cases to `src/Application`** (`add-a-use-case`). + + The entity, the repository interface, one query and one command, each with a validator. Handlers and validators are `public`, like the samples, so Mediator's source generator and the tests can see them. + + + - src/Application/Cinemas/ + - Entities/Cinema.cs + - ICinemasRepository.cs + - Queries/GetCinemas/ + - GetCinemasQuery.cs + - GetCinemasHandler.cs + - GetCinemasValidator.cs + - Commands/CreateCinema/ + - CreateCinemaCommand.cs + - CreateCinemaHandler.cs + - CreateCinemaValidator.cs + + + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; + + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); + ``` + + ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas; + + using Common.Paging; + using Entities; + + public interface ICinemasRepository + { + public Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken); + + public Task CreateCinema(string name, CancellationToken cancellationToken); + } + ``` + + The query reuses the shared paging and search rules, so `Common/Paging` stays covered once the sample queries are gone. + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + /// + /// Lists cinemas one page at a time, optionally only those whose name contains a search term. + /// + public class GetCinemasQuery : IRequest> + { + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + public string? Query { get; init; } + + /// The number of results to skip, 0 for the first page. + public int Offset { get; init; } + + /// The largest number of results to return, from 1 to 50. + public int Limit { get; init; } = Paging.DefaultLimit; + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + public class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + { + public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) + { + return await repository.GetCinemas(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using FluentValidation; + + public class GetCinemasValidator : AbstractValidator + { + public GetCinemasValidator() + { + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); + _ = this.RuleFor(r => r.Offset).ValidOffset(); + _ = this.RuleFor(r => r.Limit).ValidLimit(); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaCommand : IRequest + { + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) + { + return await repository.CreateCinema(request.Name, cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using FluentValidation; + + public class CreateCinemaValidator : AbstractValidator + { + public CreateCinemaValidator() + { + _ = this.RuleFor(r => r.Name).NotEmpty().WithMessage("A cinema needs a name."); + _ = this.RuleFor(r => r.Name).MaximumLength(100).WithMessage("A cinema name can't be longer than 100 characters."); + } + } + ``` + +2. **Store and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly`). + + Add the database model and its configuration beside the sample ones. Every model inherits `Entity`, which brings the id and the created and modified dates, and every configuration inherits `EntityConfiguration`, which sets them up. + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; + + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal record Cinema : Entity + { + public required string Name { get; init; } + } + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Models; + + internal class CinemaConfiguration : EntityConfiguration + { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(c => c.Name).HasMaxLength(100).UseCollation(Collations.NoCase); + _ = builder.HasIndex(c => c.Name); + } + } + ``` + + Then wire it in: + + - **`MovieReviewsDbContext.cs`:** add `public DbSet Cinemas => this.Set();` beside the other sets. + - **`Mapping/MovieReviewsMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using InfraCinema = Models.Cinema;` with the other aliases, and this method: + + ```csharp + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + public partial AppCinema ToDomain(InfraCinema cinema); + ``` + + - **`EntityFrameworkMovieReviewsRepository.cs`:** add `using Application.Cinemas;` and `using ApplicationCinema = Application.Cinemas.Entities.Cinema;`, add `ICinemasRepository` to the interfaces the class implements, and add a `Cinemas` region. The search reuses the repository's own `ContainsPattern` and `EscapeCharacter`, so a `%` or `_` in a search term matches itself, the same as for movies. + + ```csharp + #region Cinemas + + public async Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken) + { + var cinemas = this.context.Cinemas.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); + + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, pattern, EscapeCharacter)); + } + + var totalCount = await cinemas.CountAsync(cancellationToken); + + var result = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .ToListAsync(cancellationToken); + + return new([.. result.Select(this.mapper.ToDomain)], page.Offset, page.Limit, totalCount); + } + + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + var cinema = new Cinema + { + Name = name, + DateCreated = now, + DateModified = now + }; + + _ = this.context.Add(cinema); + + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + #endregion Cinemas + ``` + + - **`src/Infrastructure/DependencyInjection.cs`:** add `using Application.Cinemas;` and register the interface against the same repository, like the others: + + ```csharp + _ = services.AddScoped(x => + x.GetRequiredService()); + ``` + + - **`Extensions/MovieReviewsDbContextExtensions.cs`** (`sample-data`): give `local` and `docker` some cinemas to look at. Add this to `AddData` before `SaveChanges`: + + ```csharp + var cinemas = new Faker() + .UseSeed(Seed) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => $"{f.Address.City()} Picture House") + .RuleFor(c => c.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) + .RuleFor(c => c.DateModified, f => f.Date.Past(refDate: ReferenceDate)) + .Generate(5); + + context.AddRange(cinemas); + ``` + +3. **Add a migration** (`database-migrations`). + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add AddCinemas \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Without `--namespace` the migration lands in a namespace of its own. The `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// ` the first line of the new `_AddCinemas.cs`, as it is in the other migrations, so the analysers leave generated code alone. + +4. **Put it on the schema in `src/Presentation`** (`expose-a-use-case` and `xml-docs-and-openapi`). + + A type says what a cinema looks like to a client, a query lists them and a mutation creates one. The XML comments become the descriptions in the schema, so Nitro and every client's tooling show them. + + ```csharp title="src/Presentation/GraphQL/Types/CinemaType.cs" + namespace WorksOnMyMachine.Movies.Presentation.GraphQL.Types; + + using Application.Cinemas.Entities; + using HotChocolate.Types; + + [ObjectType] + public static partial class CinemaType + { + static partial void Configure(IObjectTypeDescriptor descriptor) + { + _ = descriptor.Description("A cinema that shows movies."); + _ = descriptor.Field(c => c.Id); + _ = descriptor.Field(c => c.Name).Type>(); + } + } + ``` + + ```csharp title="src/Presentation/GraphQL/Queries/CinemaQueries.cs" + namespace WorksOnMyMachine.Movies.Presentation.GraphQL.Queries; + + using Application.Cinemas.Entities; + using Application.Common.Paging; + using Extensions; + using HotChocolate.Types; + using HotChocolate.Types.Pagination; + using Mediator; + using Cinemas = Application.Cinemas.Queries; + + [QueryType] + public static partial class CinemaQueries + { + /// + /// Lists cinemas ordered by name, one page at a time, optionally only those whose name contains a search term. + /// + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + [UsePaging(ConnectionName = "Cinemas")] + public static async Task> GetCinemas( + string? query, + int? first, + string? after, + ISender sender, + CancellationToken cancellationToken) + { + var page = await sender.Send(new Cinemas.GetCinemas.GetCinemasQuery + { + Query = query, + Offset = after.ToOffset(), + Limit = first ?? Paging.DefaultLimit + }, cancellationToken); + + return page.ToConnection(); + } + } + ``` + + `[UsePaging]` turns the shared offset paging into a cursor connection, with `first`, `after`, `nodes`, `pageInfo` and `totalCount`, the same as `movies`. + + ```csharp title="src/Presentation/GraphQL/Mutations/CinemaMutations.cs" + namespace WorksOnMyMachine.Movies.Presentation.GraphQL.Mutations; + + using Application.Cinemas.Entities; + using Errors; + using HotChocolate.Types; + using Mediator; + using Commands = Application.Cinemas.Commands; + + /// + /// The cinema mutations. Each one takes a single input and returns a payload holding either the result or the typed + /// errors. + /// + [MutationType] + public static partial class CinemaMutations + { + /// + /// Creates a cinema and returns it. + /// + /// The name of the cinema, up to 100 characters. + [Error] + public static async Task CreateCinema(string name, ISender sender, CancellationToken cancellationToken) + { + return await sender.Send(new Commands.CreateCinema.CreateCinemaCommand { Name = name }, cancellationToken); + } + } + ``` + + Mutation conventions do the rest: the schema gets a `CreateCinemaInput`, a `CreateCinemaPayload` with the `cinema` and an `errors` list, and a validation failure comes back in `errors` as a `ValidationError` rather than as a failed request. + + There's nothing to register. The source generator finds `[QueryType]`, `[MutationType]` and `[ObjectType]` classes by themselves. If you created the solution with `--include-auth`, add `using HotChocolate.Authorization;` and put `[Authorize]` on both methods, as the sample queries and mutations have it. + +5. **Test the new slice** (`test-design`, `unit-tests`, `integration-tests` and `schema-snapshot`). + + Each new test has a sample test that already does the same job for movies or reviews. Copy it and rename it, and you have the shape, the names and the house style without having to think about them. + + | New test | Copy from | + | --- | --- | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasHandlerTests.cs` | `Movies/Queries/GetMovies/GetMoviesHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasValidatorTests.cs` | `Movies/Queries/GetMovies/GetMoviesValidatorTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaHandlerTests.cs` | `Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaValidatorTests.cs` | `Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs` | + | A `Cinemas` region in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs` | The `Movies` region: search, paging and create | + | `tests/Presentation.Tests.Integration/GraphQL/Cinemas/CinemaTests.cs` and `CinemaData.cs` | `Movies/MovieTests.cs` for the list, `Reviews/ReviewTests.cs` for the mutation and its validation error | + + The repository tests need some cinemas. Add three to `AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs`, named the same way as the sample rows: + + ```csharp + List cinemas = + [ + new() { Id = Guid.NewGuid(), Name = "One" }, + new() { Id = Guid.NewGuid(), Name = "Two" }, + new() { Id = Guid.NewGuid(), Name = "Three" } + ]; + + context.Cinemas.AddRange(cinemas); + ``` + + The integration tests read the response into `GraphQL/Common/Data.cs`, so add the new fields there: + + ```csharp + [JsonPropertyName("cinemas")] + public Connection Cinemas { get; set; } = null!; + + [JsonPropertyName("createCinema")] + public Cinemas.CinemaPayload? CreateCinema { get; set; } + ``` + + The schema snapshot test fails as soon as the new fields exist. It's doing its job: `schema.graphql` is the schema clients see, and every change to it should show up in a pull request. Run the app, open `http://localhost:7330/graphql?sdl` and copy what it returns into `tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql`. Read the diff before you commit it. It should only add cinemas. + + ```bash + dotnet build + dotnet test + dotnet format --verify-no-changes + ``` + + Both domains now run side by side. Stop here if you're keeping the sample for reference, which is a perfectly respectable choice. + +6. **Take out the sample movies, authors and reviews** (`sample-data` and `database-migrations`). + + This step is mostly deleting, but the template's own tests use the sample as their test subject, so a few of them change instead of going. Work through it layer by layer and build as you go. The compiler finds what's left faster than any list. + + - **Application:** delete `src/Application/Authors/`, `Movies/` and `Reviews/`. In `Common/Enums/EntityType.cs`, replace `Author`, `Movie` and `Review` with `Cinema`. `NotFoundException` uses it, and you'll want it the day you add `GetCinemaById`. + - **Infrastructure:** delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/`, and the matching `*Configuration.cs` files. Then remove from: + - `MovieReviewsDbContext.cs`: their three `DbSet` properties. + - `MovieReviewsMapper.cs`: their aliases and mapping methods. + - `EntityFrameworkMovieReviewsRepository.cs`: the `Authors`, `Movies`, `Reviews` and `Helpers` regions, their `using` lines and aliases, and their three interfaces. + - `DependencyInjection.cs`: their three registrations and `using` lines. + - `MovieReviewsDbContextExtensions.cs`: the titles, the review texts and the author, movie and review fakers. Change the guard at the top of `AddData` to `context.Cinemas.Any()`. + - **Presentation:** delete `GraphQL/DataLoaders/`, `GraphQL/Mutations/ReviewMutations.cs`, the `Author`, `Movie` and `Review` files in `GraphQL/Queries/`, and every type in `GraphQL/Types/` except `CinemaType.cs`. + - **Migrations:** replace the history with one migration for your schema. With the old snapshot gone, the tool files the new one under a folder named after the namespace, so move it back beside the migration. Then add `// ` to the first line of `_InitialCreate.cs`, as in step 3. + + ```bash + rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + dotnet ef migrations add InitialCreate \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + mv src/Infrastructure/WorksOnMyMachine/Movies/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs \ + src/Infrastructure/Databases/MovieReviews/Migrations/ + rm -r src/Infrastructure/WorksOnMyMachine + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Delete `src/Presentation/movie-reviews.db` too, if you've run the API. It still holds the old tables, and the new migration history won't recognise it. + + - **Tests to delete:** + - `tests/Application.Tests.Unit/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `tests/Presentation.Tests.Integration/GraphQL/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `EntityFrameworkMovieReviewsRepositoryTests.cs`: the `Authors`, `Movies` and `Reviews` regions. + - `MovieReviewsCollectionData.cs`: the author, movie and review rows. + - **Tests to change:** these test the template's plumbing, and they used the sample as their subject. Point them at cinemas. + + | Test | Change | + | --- | --- | + | `Common/Behaviours/LoggingBehaviourTests.cs` and `ValidationBehaviourTests.cs` | Use `CreateCinemaCommand`, `CreateCinemaValidator` and `Cinema` in place of `GetAuthorByIdQuery`, its validator and `Author`. The invalid case is an empty `Name`, so the expected property is `Name`. The logged message names `CreateCinemaCommand`. | + | `Common/Exceptions/NotFoundExceptionTests.cs` | Use `EntityType.Cinema` and a `Cinema`. The message becomes "The Cinema with the supplied id was not found." | + | `MovieReviewsConfigurationTests.cs` | Check a cinema loads, ignoring case: look up `"one"` and expect `"One"`. | + | `DependencyInjectionTests.cs` | Expect `ICinemasRepository`, 5 seeded cinemas and none when seeding is off. Use `GetCinemas` for the concurrency and same-sample-data checks. | + | `GraphQL/Common/Data.cs` and `Payload.cs` | Keep `cinemas`, `version` and `createCinema` in `Data`, and drop `ReviewPayload` and `DeleteReviewPayload`. | + | `GraphQL/Schema/schema.graphql` | Copy the new SDL in again, as in step 5. `NotFoundError` goes with the review mutations, because nothing left can raise it. | + | `Telemetry/TelemetryTests.cs` | Send `{cinemas(first:5){nodes{id}}}` and expect a `GetCinemasQuery` span. | + | `Authentication/AuthenticationTests.cs`, only with `--include-auth` | Send `{cinemas{nodes{id}}}` in place of the movie, author and review queries, and check `Cinemas` and `Version` come back empty. | + +7. **Check it the way CI will** (`coverage`). + + ```bash + dotnet build + dotnet format --verify-no-changes + dotnet test -m:1 \ + -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ + -p:CoverletOutputFormat=opencover%2cjson \ + -p:ExcludeByFile="**/Program.cs%2c**/Migrations/*.cs%2c**/obj/**/*.cs" \ + -p:ExcludeByAttribute="GeneratedCodeAttribute" + ``` + + The last table it prints is the merged total. Ours came out at 99.1%. If yours is under 95%, `coverage` says what to do about it, and it isn't "lower the number". + + + +## What's still called "movie reviews" + +The database plumbing keeps its sample name: the `Databases/MovieReviews` folder, `MovieReviewsDbContext`, `EntityFrameworkMovieReviewsRepository`, the `MovieReviews` connection string and the `movie-reviews.db` file. They all work as they are. Everything in `src/Infrastructure` is `internal`, so renaming them later is a refactor that never reaches Application or Presentation. Do it when the name starts to bother you, and change the connection string in every `appsettings.*.json` and in `docker-compose.yml` at the same time. diff --git a/src/content/docs/lambda/getting-started.mdx b/src/content/docs/lambda/getting-started.mdx index db158cb..e1c8ebb 100644 --- a/src/content/docs/lambda/getting-started.mdx +++ b/src/content/docs/lambda/getting-started.mdx @@ -99,6 +99,7 @@ Hit a snag? Check [Troubleshooting](/lambda/reference/troubleshooting/). + diff --git a/src/content/docs/lambda/guides/make-it-yours.mdx b/src/content/docs/lambda/guides/make-it-yours.mdx new file mode 100644 index 0000000..b923c8b --- /dev/null +++ b/src/content/docs/lambda/guides/make-it-yours.mdx @@ -0,0 +1,541 @@ +--- +title: "Make it yours" +description: "Add your own entity end to end, then clear out the sample movies, authors and reviews without dropping below the 95% coverage gate." +sidebar: + order: 2 +--- + +import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; + +Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, the serializer and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. + +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with a `GetCinemas` function to list and search them and a `CreateCinema` function to add one, each with an API Gateway twin at `GET /cinemas` and `POST /cinemas`, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 75 passing tests and 99.7% line coverage. + + + +The code below assumes you named the solution `WorksOnMyMachine.Movies`. Swap in your own name wherever you see it. + + + +1. **Add the entity and use cases to `src/Application`** (`add-a-use-case`). + + The entity, the repository interface, one query and one command, each with a validator. Handlers and validators are `public`, like the samples, so Mediator's source generator and the tests can see them. + + + - src/Application/Cinemas/ + - Entities/Cinema.cs + - ICinemasRepository.cs + - Queries/GetCinemas/ + - GetCinemasQuery.cs + - GetCinemasHandler.cs + - GetCinemasValidator.cs + - Commands/CreateCinema/ + - CreateCinemaCommand.cs + - CreateCinemaHandler.cs + - CreateCinemaValidator.cs + + + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; + + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); + ``` + + ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas; + + using Common.Paging; + using Entities; + + public interface ICinemasRepository + { + public Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken); + + public Task CreateCinema(string name, CancellationToken cancellationToken); + } + ``` + + The query reuses the shared paging and search rules, so `Common/Paging` stays covered once the sample queries are gone. + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + /// + /// Lists cinemas one page at a time, optionally only those whose name contains a search term. + /// + public class GetCinemasQuery : IRequest> + { + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + public string? Query { get; init; } + + /// The number of results to skip, 0 for the first page. + public int Offset { get; init; } + + /// The largest number of results to return, from 1 to 50. + public int Limit { get; init; } = Paging.DefaultLimit; + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + public class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + { + public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) + { + return await repository.GetCinemas(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using FluentValidation; + + public class GetCinemasValidator : AbstractValidator + { + public GetCinemasValidator() + { + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); + _ = this.RuleFor(r => r.Offset).ValidOffset(); + _ = this.RuleFor(r => r.Limit).ValidLimit(); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaCommand : IRequest + { + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) + { + return await repository.CreateCinema(request.Name, cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using FluentValidation; + + public class CreateCinemaValidator : AbstractValidator + { + public CreateCinemaValidator() + { + _ = this.RuleFor(r => r.Name).NotEmpty().WithMessage("A cinema needs a name."); + _ = this.RuleFor(r => r.Name).MaximumLength(100).WithMessage("A cinema name can't be longer than 100 characters."); + } + } + ``` + +2. **Store and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly`). + + Add the database model and its configuration beside the sample ones. Every model inherits `Entity`, which brings the id and the created and modified dates, and every configuration inherits `EntityConfiguration`, which sets them up. + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; + + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal record Cinema : Entity + { + public required string Name { get; init; } + } + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Models; + + internal class CinemaConfiguration : EntityConfiguration + { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(c => c.Name).HasMaxLength(100).UseCollation(Collations.NoCase); + _ = builder.HasIndex(c => c.Name); + } + } + ``` + + Then wire it in: + + - **`MovieReviewsDbContext.cs`:** add `public DbSet Cinemas => this.Set();` beside the other sets. + - **`Mapping/MovieReviewsMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using InfraCinema = Models.Cinema;` with the other aliases, and this method: + + ```csharp + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + public partial AppCinema ToDomain(InfraCinema cinema); + ``` + + - **`EntityFrameworkMovieReviewsRepository.cs`:** add `using Application.Cinemas;` and `using ApplicationCinema = Application.Cinemas.Entities.Cinema;`, add `ICinemasRepository` to the interfaces the class implements, and add a `Cinemas` region. The search reuses the repository's own `ContainsPattern` and `EscapeCharacter`, so a `%` or `_` in a search term matches itself, the same as for movies. + + ```csharp + #region Cinemas + + public async Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken) + { + var cinemas = this.context.Cinemas.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); + + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, pattern, EscapeCharacter)); + } + + var totalCount = await cinemas.CountAsync(cancellationToken); + + var result = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .ToListAsync(cancellationToken); + + return new([.. result.Select(this.mapper.ToDomain)], page.Offset, page.Limit, totalCount); + } + + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + var cinema = new Cinema + { + Name = name, + DateCreated = now, + DateModified = now + }; + + _ = this.context.Add(cinema); + + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + #endregion Cinemas + ``` + + - **`src/Infrastructure/DependencyInjection.cs`:** add `using Application.Cinemas;` and register the interface against the same repository, like the others: + + ```csharp + _ = services.AddScoped(x => + x.GetRequiredService()); + ``` + + - **`Extensions/MovieReviewsDbContextExtensions.cs`** (`sample-data`): give `local` and `docker` some cinemas to look at. Add this to `AddData` before `SaveChanges`: + + ```csharp + var cinemas = new Faker() + .UseSeed(Seed) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => $"{f.Address.City()} Picture House") + .RuleFor(c => c.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) + .RuleFor(c => c.DateModified, f => f.Date.Past(refDate: ReferenceDate)) + .Generate(5); + + context.AddRange(cinemas); + ``` + +3. **Add a migration** (`database-migrations`). + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add AddCinemas \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Without `--namespace` the migration lands in a namespace of its own. The `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// ` the first line of the new `_AddCinemas.cs`, as it is in the other migrations, so the analysers leave generated code alone. + +4. **Put it on functions in `src/Presentation`** (`expose-a-use-case`, `api-conventions` and `json-serialization`). + + Every use case goes out twice, like the samples: a function you invoke directly with a request record, and an HTTP API function that API Gateway calls with the request in the path, query or body. Both share one private method, so the use case is only sent from one place. + + The function returns its own models, not the Application entities, so what goes over the wire is decided here. + + ```csharp title="src/Presentation/LambdaFunction/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Presentation.LambdaFunction.Models; + + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); + ``` + + ```csharp title="src/Presentation/LambdaFunction/Models/CreateCinemaRequest.cs" + namespace WorksOnMyMachine.Movies.Presentation.LambdaFunction.Models; + + /// + /// Creates a cinema. + /// + public record CreateCinemaRequest + { + /// The name of the cinema, up to 100 characters. + public string Name { get; init; } = string.Empty; + } + ``` + + Then in `src/Presentation/LambdaFunction/`: + + - **`Mapping/LambdaFunctionMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using PresentationCinema = Models.Cinema;` with the other aliases, and `public partial PresentationCinema ToPresentation(AppCinema cinema);`. + - **`Serialization/LambdaFunctionJsonSerializerContext.cs`:** add `[JsonSerializable(typeof(CreateCinemaRequest))]`, `[JsonSerializable(typeof(Cinema))]` and `[JsonSerializable(typeof(Page))]`. Native AOT can't reflect, so a type that isn't listed here can't be read or written. + - **`LambdaFunction.cs`:** add `using Application.Cinemas.Commands.CreateCinema;` and `using Application.Cinemas.Queries.GetCinemas;`, then the two functions and the methods they share: + + ```csharp + /// + /// Lists cinemas ordered by name, one page at a time, optionally only those whose name contains a search term. + /// + /// The search term, offset and limit. + /// The Lambda invocation context. + /// One page of cinemas. + [LambdaFunction(ResourceName = "GetCinemas", PackageType = LambdaPackageType.Image, MemorySize = 256, Timeout = 30)] + public async Task> GetCinemas(ListRequest request, ILambdaContext context) + { + return await this.Invoke(request, context, () => this.ListCinemas(request, context)); + } + + /// + /// Creates a cinema and returns it with its new id. + /// + /// The name of the cinema. + /// The Lambda invocation context. + /// The cinema that was created. + [LambdaFunction(ResourceName = "CreateCinema", PackageType = LambdaPackageType.Image, MemorySize = 256, Timeout = 30)] + public async Task CreateCinema(CreateCinemaRequest request, ILambdaContext context) + { + return await this.Invoke(request, context, () => this.AddCinema(request, context)); + } + + private async Task> ListCinemas(ListRequest request, ILambdaContext context) + { + var page = await this.Send(new GetCinemasQuery + { + Query = request.Query, + Offset = request.Offset, + Limit = request.Limit + }, context); + + return ToPage(page, this.mapper.ToPresentation); + } + + private async Task AddCinema(CreateCinemaRequest request, ILambdaContext context) + { + return this.mapper.ToPresentation(await this.Send(new CreateCinemaCommand { Name = request.Name }, context)); + } + ``` + + And their HTTP API twins, beside the sample ones: + + ```csharp + /// + /// The same list as , for API Gateway: GET /cinemas?query=&offset=&limit=. + /// + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + /// The number of cinemas to skip. + /// The most cinemas to return, from 1 to 50. + /// The Lambda invocation context. + /// 200 with one page of cinemas, or 400 with problem details when the request is invalid. + [LambdaFunction(ResourceName = "HttpGetCinemas", PackageType = LambdaPackageType.Image, MemorySize = 256, Timeout = 30)] + [HttpApi(LambdaHttpMethod.Get, "/cinemas")] + public async Task HttpGetCinemas( + [FromQuery] string query, + [FromQuery] int? offset, + [FromQuery] int? limit, + ILambdaContext context) + { + var request = new ListRequest + { + Query = EmptyAsNull(query), + Offset = offset ?? 0, + Limit = limit ?? AppPaging.Paging.DefaultLimit + }; + + return await this.Invoke(request, context, () => Http(() => this.ListCinemas(request, context))); + } + + /// + /// The same as , for API Gateway: POST /cinemas with the name in the body. + /// + /// The name of the cinema. + /// The Lambda invocation context. + /// 201 with the cinema, or 400 with problem details when the request is invalid. + [LambdaFunction(ResourceName = "HttpCreateCinema", PackageType = LambdaPackageType.Image, MemorySize = 256, Timeout = 30)] + [HttpApi(LambdaHttpMethod.Post, "/cinemas")] + public async Task HttpCreateCinema([FromBody] CreateCinemaRequest request, ILambdaContext context) + { + return await this.Invoke(request, context, () => Http(() => this.AddCinema(request, context), HttpResults.Created)); + } + ``` + + A create should answer `201 Created`, and the `Http` helper only knew `200 OK`. Give it an optional `success` that turns the result into a response, so `HttpResults.Created` can take over, and update its summary to say so: + + ```csharp + private static async Task Http(Func> function, Func? success = null) + { + try + { + var result = await function(); + + return success is null ? HttpResults.Ok(result) : success(null, result); + } + catch (ValidationException exception) + ... + ``` + + The build regenerates `src/Presentation/serverless.template` from the attributes, so the four new functions show up there. Commit it with the code. + +5. **Test the new slice** (`test-design`, `unit-tests`, `integration-tests` and `presentation-integration-tests`). + + Each new test has a sample test that already does the same job for movies or reviews. Copy it and rename it, and you have the shape, the names and the house style without having to think about them. + + | New test | Copy from | + | --- | --- | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasHandlerTests.cs` | `Movies/Queries/GetMovies/GetMoviesHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasValidatorTests.cs` | `Movies/Queries/GetMovies/GetMoviesValidatorTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaHandlerTests.cs` | `Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaValidatorTests.cs` | `Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs` | + | A `Cinemas` region in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs` | The `Movies` region: search, paging and create | + | `GetCinemas_ShouldReturn_FirstPage`, `CreateCinema_ShouldReturn_Cinema` and `CreateCinema_ShouldThrow_WhenNameIsEmpty` in `tests/Presentation.Tests.Integration/HandlerTests.cs` | The `GetMovies` tests in the same file | + | `HttpGetCinemas_ShouldReturn_Ok`, `HttpCreateCinema_ShouldReturn_Created` and `HttpCreateCinema_ShouldReturn_BadRequest_WhenNameIsEmpty` in `tests/Presentation.Tests.Integration/HttpApiTests.cs` | The `HttpGetMovies` tests in the same file | + + The repository tests need some cinemas. Add three to `AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs`, named the same way as the sample rows: + + ```csharp + List cinemas = + [ + new() { Id = Guid.NewGuid(), Name = "One" }, + new() { Id = Guid.NewGuid(), Name = "Two" }, + new() { Id = Guid.NewGuid(), Name = "Three" } + ]; + + context.Cinemas.AddRange(cinemas); + ``` + + ```bash + dotnet build + dotnet test + dotnet format --verify-no-changes + ``` + + Both domains now run side by side. Stop here if you're keeping the sample for reference, which is a perfectly respectable choice. + +6. **Take out the sample movies, authors and reviews** (`sample-data` and `database-migrations`). + + This step is mostly deleting, but the template's own tests use the sample as their test subject, so a few of them change instead of going. Work through it layer by layer and build as you go. The compiler finds what's left faster than any list. + + - **Application:** delete `src/Application/Authors/`, `Movies/` and `Reviews/`. In `Common/Enums/EntityType.cs`, replace `Author`, `Movie` and `Review` with `Cinema`. `NotFoundException` uses it, and you'll want it the day you add `GetCinemaById`. + - **Infrastructure:** delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/`, and the matching `*Configuration.cs` files. Then remove from: + - `MovieReviewsDbContext.cs`: their three `DbSet` properties. + - `MovieReviewsMapper.cs`: their aliases and mapping methods. + - `EntityFrameworkMovieReviewsRepository.cs`: the `Authors`, `Movies`, `Reviews` and `Helpers` regions, their `using` lines and aliases, and their three interfaces. + - `DependencyInjection.cs`: their three registrations and `using` lines. + - `MovieReviewsDbContextExtensions.cs`: the titles, the review texts and the author, movie and review fakers. Change the guard at the top of `AddData` to `context.Cinemas.Any()`. + - **Presentation:** delete `Author.cs`, `Movie.cs`, `Review.cs` and `ListReviewsRequest.cs` from `LambdaFunction/Models/`. Then remove from: + - `LambdaFunction.cs`: `GetMovies`, `GetAuthors` and `GetReviews`, their `Http` twins, the private methods behind them, `ParseId`, which only the review functions used, and their `using` lines. Move the "This is the function the image runs by default." line to `GetCinemas`, and make the class summary's example `Handler_GetCinemas_Generated`. + - `LambdaFunctionMapper.cs`: their aliases and mapping methods. + - `LambdaFunctionJsonSerializerContext.cs`: their `[JsonSerializable]` lines. + - `Models/ListRequest.cs`: the summary, which says it lists movies and authors. Make it "Lists cinemas one page at a time". + - **`Dockerfile`:** the image runs `GetMovies` by default, which is gone. Point the `CMD` at `Presentation::WorksOnMyMachine.Movies.Presentation.LambdaFunction.Handler_GetCinemas_Generated::GetCinemas`. + - **`docs/RUN.md`** and the `lambda-emulator` skill: their examples invoke the sample functions. Swap in `GetCinemas` and `HttpGetCinemas`. + - **Migrations:** replace the history with one migration for your schema. With the old snapshot gone, the tool files the new one under a folder named after the namespace, so move it back beside the migration. Then add `// ` to the first line of `_InitialCreate.cs`, as in step 3. + + ```bash + rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + dotnet ef migrations add InitialCreate \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + mv src/Infrastructure/WorksOnMyMachine/Movies/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs \ + src/Infrastructure/Databases/MovieReviews/Migrations/ + rm -r src/Infrastructure/WorksOnMyMachine + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + - **Tests to delete:** + - `tests/Application.Tests.Unit/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `EntityFrameworkMovieReviewsRepositoryTests.cs`: the `Authors`, `Movies` and `Reviews` regions. + - `MovieReviewsCollectionData.cs`: the author, movie and review rows. + - **Tests to change:** these test the template's plumbing, and they used the sample as their subject. Point them at cinemas. + + | Test | Change | + | --- | --- | + | `Common/Behaviours/LoggingBehaviourTests.cs` and `ValidationBehaviourTests.cs` | Use `CreateCinemaCommand`, `CreateCinemaValidator` and `Cinema` in place of `GetAuthorByIdQuery`, its validator and `Author`. The invalid case is an empty `Name`, so the expected property is `Name`. The logged message names `CreateCinemaCommand`. | + | `Common/Exceptions/NotFoundExceptionTests.cs` | Use `EntityType.Cinema` and a `Cinema`. The message becomes "The Cinema with the supplied id was not found." | + | `MovieReviewsConfigurationTests.cs` | Check a cinema loads, ignoring case: look up `"one"` and expect `"One"`. | + | `DependencyInjectionTests.cs` | Expect `ICinemasRepository`, 5 seeded cinemas and none when seeding is off. Use `GetCinemas` for the concurrency and same-sample-data checks. | + | `HandlerTests.cs` | Run the generated handlers and the validation checks against `GetCinemas`, which returns 5 cinemas. | + | `HttpApiTests.cs` | Keep the cinema tests, and point the generated handler test at `HttpGetCinemas`. | + | `TelemetryTests.cs` | Use `GetCinemas` for the function name, the ARN and the call, and expect a `GetCinemasQuery` span. The HTTP test calls `HttpGetCinemas` and expects one use case span, because a list has no rejection before the use case runs. | + | `Serialization/LambdaFunctionJsonSerializerTests.cs` | Drop the review test, and write a page of cinemas: `{"items":[{"id":"…","name":"Lorem Ipsum"}],"totalCount":1}`. | + +7. **Check it the way CI will** (`coverage`). + + ```bash + dotnet build + dotnet format --verify-no-changes + dotnet test -m:1 \ + -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ + -p:CoverletOutputFormat=opencover%2cjson \ + -p:ExcludeByFile="**/Program.cs%2c**/Migrations/*.cs%2c**/obj/**/*.cs" \ + -p:ExcludeByAttribute="GeneratedCodeAttribute" + ``` + + The last table it prints is the merged total. Ours came out at 99.7%. If yours is under 95%, `coverage` says what to do about it, and it isn't "lower the number". + + + +## What's still called "movie reviews" + +The database plumbing keeps its sample name: the `Databases/MovieReviews` folder, `MovieReviewsDbContext`, `EntityFrameworkMovieReviewsRepository`, the `MovieReviews` connection string and the `movie-reviews.db` file. They all work as they are. Everything in `src/Infrastructure` is `internal`, so renaming them later is a refactor that never reaches Application or Presentation. Do it when the name starts to bother you, and change the connection string's name and `DefaultConnectionString` in `LambdaFunction/Startup.cs` at the same time. diff --git a/src/content/docs/mcp/getting-started.mdx b/src/content/docs/mcp/getting-started.mdx index 7119d0b..b0d67f7 100644 --- a/src/content/docs/mcp/getting-started.mdx +++ b/src/content/docs/mcp/getting-started.mdx @@ -104,6 +104,7 @@ Hit a snag? Check [Troubleshooting](/mcp/reference/troubleshooting/). + diff --git a/src/content/docs/mcp/guides/make-it-yours.mdx b/src/content/docs/mcp/guides/make-it-yours.mdx new file mode 100644 index 0000000..9f1169b --- /dev/null +++ b/src/content/docs/mcp/guides/make-it-yours.mdx @@ -0,0 +1,689 @@ +--- +title: "Make it yours" +description: "Add your own entity end to end, then clear out the sample movies, authors and reviews without dropping below the 95% coverage gate." +sidebar: + order: 2 +--- + +import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; + +Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, the error filters, resource links, completions and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. + +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with a `cinemas_search` tool to list and search them, a `cinemas_add` tool to add one, a `cinemas://{id}` resource to read one and a `describe_cinema` prompt to write a blurb for one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 112 passing tests and 99.8% line coverage. + + + +The code below assumes you named the solution `WorksOnMyMachine.Movies`. Swap in your own name wherever you see it. + + + +1. **Add the entity and use cases to `src/Application`** (`add-a-use-case`). + + The entity, the repository interface, two queries and one command, each with a validator. An MCP server needs the second query, `GetCinemaById`, more than an API does: every cinema a tool returns comes with a link to a `cinemas://{id}` resource, and something has to answer when a client opens it. Handlers and validators are `public`, like the samples, so Mediator's source generator and the tests can see them. + + + - src/Application/Cinemas/ + - Entities/Cinema.cs + - ICinemasRepository.cs + - Queries/GetCinemas/ + - GetCinemasQuery.cs + - GetCinemasHandler.cs + - GetCinemasValidator.cs + - Queries/GetCinemaById/ + - GetCinemaByIdQuery.cs + - GetCinemaByIdHandler.cs + - GetCinemaByIdValidator.cs + - Commands/CreateCinema/ + - CreateCinemaCommand.cs + - CreateCinemaHandler.cs + - CreateCinemaValidator.cs + + + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; + + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); + ``` + + ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas; + + using Common.Paging; + using Entities; + + public interface ICinemasRepository + { + public Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken); + + public Task GetCinemaById(Guid id, CancellationToken cancellationToken); + + public Task CreateCinema(string name, CancellationToken cancellationToken); + } + ``` + + + The query reuses the shared paging and search rules, so `Common/Paging` stays covered once the sample queries are gone. + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + /// + /// Lists cinemas one page at a time, optionally only those whose name contains a search term. + /// + public class GetCinemasQuery : IRequest> + { + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + public string? Query { get; init; } + + /// The number of results to skip, 0 for the first page. + public int Offset { get; init; } + + /// The largest number of results to return, from 1 to 50. + public int Limit { get; init; } = Paging.DefaultLimit; + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + public class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + { + public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) + { + return await repository.GetCinemas(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using FluentValidation; + + public class GetCinemasValidator : AbstractValidator + { + public GetCinemasValidator() + { + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); + _ = this.RuleFor(r => r.Offset).ValidOffset(); + _ = this.RuleFor(r => r.Limit).ValidLimit(); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemaById/GetCinemaByIdQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemaById; + + using Entities; + using Mediator; + + public class GetCinemaByIdQuery : IRequest + { + public Guid Id { get; init; } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemaById/GetCinemaByIdHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemaById; + + using Common.Enums; + using Common.Exceptions; + using Entities; + using Mediator; + + public class GetCinemaByIdHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(GetCinemaByIdQuery request, CancellationToken cancellationToken) + { + var result = await repository.GetCinemaById(request.Id, cancellationToken); + + NotFoundException.ThrowIfNull(result, EntityType.Cinema); + + return result; + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemaById/GetCinemaByIdValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemaById; + + using FluentValidation; + + public class GetCinemaByIdValidator : AbstractValidator + { + public GetCinemaByIdValidator() + { + _ = this.RuleFor(r => r.Id).NotEqual(Guid.Empty).WithMessage("A Cinema Id was not supplied."); + } + } + ``` + + Add `Cinema` to `Common/Enums/EntityType.cs` too, so `NotFoundException` can name what it didn't find. + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaCommand : IRequest + { + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) + { + return await repository.CreateCinema(request.Name, cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using FluentValidation; + + public class CreateCinemaValidator : AbstractValidator + { + public CreateCinemaValidator() + { + _ = this.RuleFor(r => r.Name).NotEmpty().WithMessage("A cinema needs a name."); + _ = this.RuleFor(r => r.Name).MaximumLength(100).WithMessage("A cinema name can't be longer than 100 characters."); + } + } + ``` + +2. **Store and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly`). + + Add the database model and its configuration beside the sample ones. Every model inherits `Entity`, which brings the id and the created and modified dates, and every configuration inherits `EntityConfiguration`, which sets them up. + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; + + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal record Cinema : Entity + { + public required string Name { get; init; } + } + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Models; + + internal class CinemaConfiguration : EntityConfiguration + { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(c => c.Name).HasMaxLength(100).UseCollation(Collations.NoCase); + _ = builder.HasIndex(c => c.Name); + } + } + ``` + + Then wire it in: + + - **`MovieReviewsDbContext.cs`:** add `public DbSet Cinemas => this.Set();` beside the other sets. + - **`Mapping/MovieReviewsMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using InfraCinema = Models.Cinema;` with the other aliases, and this method: + + ```csharp + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + public partial AppCinema ToDomain(InfraCinema cinema); + ``` + + - **`EntityFrameworkMovieReviewsRepository.cs`:** add `using Application.Cinemas;` and `using ApplicationCinema = Application.Cinemas.Entities.Cinema;`, add `ICinemasRepository` to the interfaces the class implements, and add a `Cinemas` region. The search reuses the repository's own `ContainsPattern` and `EscapeCharacter`, so a `%` or `_` in a search term matches itself, the same as for movies. + + ```csharp + #region Cinemas + + public async Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken) + { + var cinemas = this.context.Cinemas.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); + + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, pattern, EscapeCharacter)); + } + + var totalCount = await cinemas.CountAsync(cancellationToken); + + var result = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .ToListAsync(cancellationToken); + + return new([.. result.Select(this.mapper.ToDomain)], page.Offset, page.Limit, totalCount); + } + + public async Task GetCinemaById(Guid id, CancellationToken cancellationToken) + { + var result = await this.context.Cinemas.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + return result is null ? null : this.mapper.ToDomain(result); + } + + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + var cinema = new Cinema + { + Name = name, + DateCreated = now, + DateModified = now + }; + + _ = this.context.Add(cinema); + + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + #endregion Cinemas + ``` + + - **`src/Infrastructure/DependencyInjection.cs`:** add `using Application.Cinemas;` and register the interface against the same repository, like the others: + + ```csharp + _ = services.AddScoped(x => + x.GetRequiredService()); + ``` + + - **`Extensions/MovieReviewsDbContextExtensions.cs`** (`sample-data`): give `local` and `docker` some cinemas to look at. Add this to `AddData` before `SaveChanges`: + + ```csharp + var cinemas = new Faker() + .UseSeed(Seed) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => $"{f.Address.City()} Picture House") + .RuleFor(c => c.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) + .RuleFor(c => c.DateModified, f => f.Date.Past(refDate: ReferenceDate)) + .Generate(5); + + context.AddRange(cinemas); + ``` + +3. **Add a migration** (`database-migrations`). + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add AddCinemas \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Without `--namespace` the migration lands in a namespace of its own. The `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// ` the first line of the new `_AddCinemas.cs`, as it is in the other migrations, so the analysers leave generated code alone. + +4. **Put it on tools, a resource and a prompt in `src/Presentation`** (`expose-a-use-case`, `api-conventions` and `json-serialization`). + + This is where an MCP server earns its keep. A model reads the tool names, titles, annotations and descriptions to decide what to call, so they're written for a model to read, the same way the sample's are. Tools return a result model rather than the Application entity, so the server decides exactly what a client sees. + + ```csharp title="src/Presentation/Models/CinemaResult.cs" + namespace WorksOnMyMachine.Movies.Presentation.Models; + + using System.ComponentModel; + + /// + /// A cinema that shows movies. + /// + /// The cinema's id. + /// The cinema's name. + public record CinemaResult( + [property: Description("The cinema's id.")] Guid Id, + [property: Description("The cinema's name.")] string Name); + ``` + + ```csharp title="src/Presentation/Tools/CinemaTools.cs" + namespace WorksOnMyMachine.Movies.Presentation.Tools; + + using Mediator; + using ModelContextProtocol.Server; + using Presentation.Models; + using Commands = Application.Cinemas.Commands; + using Queries = Application.Cinemas.Queries; + + [McpServerToolType] + public static partial class CinemaTools + { + /// + /// Find cinemas by name, ignoring case, in name order. Each result has the cinema's id and name. Leave the query + /// empty to browse every cinema a page at a time. Use this to check a cinema isn't already there before calling + /// cinemas_add. + /// + /// Part of the cinema's name, such as 'astor'. Leave empty to list every cinema. + /// The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page. + /// The most results to return, from 1 to 50. + [McpServerTool(Name = "cinemas_search", Title = "Search cinemas", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Cinema)] + public static async partial Task> SearchCinemas( + ISender sender, + CancellationToken cancellationToken, + string? query = null, + int offset = 0, + int limit = 10) + { + var page = await sender.Send(new Queries.GetCinemas.GetCinemasQuery + { + Query = query, + Offset = offset, + Limit = limit + }, cancellationToken); + + return page.ToResult(c => c.ToResult()); + } + + /// + /// Add a cinema and return it with its new id. Calling it twice adds two cinemas, so search first with + /// cinemas_search if you're not sure it's already there. + /// + /// The cinema's name, up to 100 characters, such as 'The Astor'. + [McpServerTool(Name = "cinemas_add", Title = "Add cinema", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Cinema)] + public static async partial Task AddCinema( + ISender sender, + string name, + CancellationToken cancellationToken) + { + var cinema = await sender.Send(new Commands.CreateCinema.CreateCinemaCommand + { + Name = name + }, cancellationToken); + + return cinema.ToResult(); + } + } + ``` + + The annotations tell the client how careful to be. `cinemas_search` is read-only and safe to repeat. `cinemas_add` changes data, so it isn't read-only, but it doesn't destroy anything, and it isn't idempotent, because calling it twice adds two cinemas. The description says so, and points the model at `cinemas_search` first. + + Every cinema a tool returns gets a resource link to `cinemas://{id}`, because the scheme is the start of the tool's name. That link has to open, so add the resource: + + ```csharp title="src/Presentation/Resources/CinemaResources.cs" + namespace WorksOnMyMachine.Movies.Presentation.Resources; + + using Mediator; + using ModelContextProtocol.Server; + using Presentation.Models; + using Presentation.Serialization; + using Presentation.Tools; + using Queries = Application.Cinemas.Queries; + + [McpServerResourceType] + public static partial class CinemaResources + { + /// A cinema, by its id. + /// The cinema's id (a GUID). + [McpServerResource(UriTemplate = "cinemas://{cinemaId}", Name = "cinema", Title = "Cinema", MimeType = "application/json", IconSource = ItemIcons.Cinema)] + public static async partial Task GetCinema( + ISender sender, + Guid cinemaId, + CancellationToken cancellationToken) + { + var cinema = await sender.Send(new Queries.GetCinemaById.GetCinemaByIdQuery + { + Id = cinemaId + }, cancellationToken); + + return McpSerializer.Serialize(cinema.ToResult()); + } + } + ``` + + And a prompt, so a client has something to offer the user for cinemas, not just the model: + + ```csharp title="src/Presentation/Prompts/CinemaPrompts.cs" + namespace WorksOnMyMachine.Movies.Presentation.Prompts; + + using Mediator; + using ModelContextProtocol.Protocol; + using ModelContextProtocol.Server; + using Presentation.Models; + using Presentation.Serialization; + using Presentation.Tools; + using Queries = Application.Cinemas.Queries; + + [McpServerPromptType] + public static partial class CinemaPrompts + { + /// Write a short, friendly blurb for a cinema's listing, from the cinema's details. + /// The cinema's id (a GUID). + [McpServerPrompt(Name = "describe_cinema", Title = "Describe a cinema", IconSource = ItemIcons.Cinema)] + public static async partial Task DescribeCinema( + ISender sender, + Guid cinemaId, + CancellationToken cancellationToken) + { + var cinema = (await sender.Send(new Queries.GetCinemaById.GetCinemaByIdQuery { Id = cinemaId }, cancellationToken)).ToResult(); + + return new GetPromptResult + { + Description = $"Describe {cinema.Name}", + Messages = + [ + PromptContent.Text($"Write a short, friendly blurb for the listing of {cinema.Name}, using the cinema below. Keep it to two sentences."), + PromptContent.Embed($"cinemas://{cinema.Id}", McpSerializer.Serialize(cinema)) + ] + }; + } + } + ``` + + Then wire it in: + + - **`Models/ResultMapper.cs`:** add `using Application.Cinemas.Entities;` and the mapping. + + ```csharp + public static CinemaResult ToResult(this Cinema cinema) + { + return new CinemaResult(cinema.Id, cinema.Name); + } + ``` + + - **`Tools/ItemIcons.cs`:** add `public const string Cinema = Source + "movie-64.png";`. Every tool, resource and prompt needs an icon, and a test checks. The movie icon will do until you have your own: point `Cinema` at its URL when you do. + - **`Serialization/AppJsonSerializerContext.cs`:** add `[JsonSerializable(typeof(CinemaResult))]` and `[JsonSerializable(typeof(ResultPage))]`. + - **`Completions/CompletionHandler.cs`:** add `using CinemaQueries = Application.Cinemas.Queries;` and a case to `CompleteResourceId`, so a client can find a cinema's id by its name or its first few characters: + + ```csharp + case "cinemaId": + { + var page = await sender.Send(new CinemaQueries.GetCinemas.GetCinemasQuery + { + Query = argument.Value, + Limit = Paging.MaxLimit + }, cancellationToken); + + if (page.Items.Count > 0) + { + return Result([.. page.Items.Select(c => c.Id.ToString())], page.TotalCount); + } + + var cinemas = await GetAll(offset => sender.Send(new CinemaQueries.GetCinemas.GetCinemasQuery + { + Offset = offset, + Limit = Paging.MaxLimit + }, cancellationToken)); + + return IdsStartingWith(cinemas.Select(c => c.Id), argument.Value); + } + ``` + + - **`Extensions/WebApplicationBuilderExtensions.cs`:** mention the cinema tools in `ServerInstructions`. The model reads them before it reads anything else. + + There's nothing else to register: the server finds tools, resources and prompts in the assembly by themselves, and with `--include-auth` the whole `/mcp` endpoint already needs a token. + +5. **Test the new slice** (`test-design`, `unit-tests`, `integration-tests` and `presentation-integration-tests`). + + Each new test has a sample test that already does the same job for movies or reviews. Copy it and rename it, and you have the shape, the names and the house style without having to think about them. + + | New test | Copy from | + | --- | --- | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasHandlerTests.cs` | `Movies/Queries/GetMovies/GetMoviesHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasValidatorTests.cs` | `Movies/Queries/GetMovies/GetMoviesValidatorTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemaById/GetCinemaByIdHandlerTests.cs` and `GetCinemaByIdValidatorTests.cs` | The tests in `Movies/Queries/GetMovieById/` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaHandlerTests.cs` | `Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaValidatorTests.cs` | `Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs` | + | A `Cinemas` region in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs` | The `Movies` region: search, paging, get by id and create | + | `tests/Presentation.Tests.Unit/Tools/CinemaToolTests.cs` | `MovieToolTests.cs` and `ReviewToolTests.cs` | + | `tests/Presentation.Tests.Integration/Tools/CinemaToolTests.cs` | `MovieToolTests.cs` and `ReviewToolTests.cs` | + + The repository tests need some cinemas. Add three to `AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs`, named the same way as the sample rows: + + ```csharp + List cinemas = + [ + new() { Id = Guid.NewGuid(), Name = "One" }, + new() { Id = Guid.NewGuid(), Name = "Two" }, + new() { Id = Guid.NewGuid(), Name = "Three" } + ]; + + context.Cinemas.AddRange(cinemas); + ``` + + A few integration tests list everything the server offers, so they fail until they know about cinemas. They're doing their job: they pin down exactly what a client sees. + + - **`Tools/ToolListTests.cs`:** add `{ "cinemas_search", "Search cinemas", true, false, true }` and `{ "cinemas_add", "Add cinema", false, false, false }` to the expected tools and their annotations. + - **`Resources/ResourceTests.cs`:** add `"cinemas://{cinemaId}"` to the expected templates, and read a cinema, an unknown id and an empty id, as the movie tests do. + - **`Prompts/PromptTests.cs`:** add `"describe_cinema"` to the expected prompts. + - **`Tools/ResourceLinkTests.cs`** and **`Completions/CompletionTests.cs`:** add a test that `cinemas_add` links the new cinema, and one that completes a `cinemaId` by name and by its first eight characters. + + ```bash + dotnet build + dotnet test + dotnet format --verify-no-changes + ``` + + Both domains now run side by side. Stop here if you're keeping the sample for reference, which is a perfectly respectable choice. + +6. **Take out the sample movies, authors and reviews** (`sample-data` and `database-migrations`). + + This step is mostly deleting, but the template's own tests use the sample as their test subject, so a few of them change instead of going. Work through it layer by layer and build as you go. The compiler finds what's left faster than any list. + + - **Application:** delete `src/Application/Authors/`, `Movies/` and `Reviews/`. In `Common/Enums/EntityType.cs`, remove `Author`, `Movie` and `Review`, which leaves `Cinema`. + - **Infrastructure:** delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/`, and the matching `*Configuration.cs` files. Then remove from: + - `MovieReviewsDbContext.cs`: their three `DbSet` properties. + - `MovieReviewsMapper.cs`: their aliases and mapping methods. + - `EntityFrameworkMovieReviewsRepository.cs`: the `Authors`, `Movies`, `Reviews` and `Helpers` regions, their `using` lines and aliases, and their three interfaces. + - `DependencyInjection.cs`: their three registrations and `using` lines. + - `MovieReviewsDbContextExtensions.cs`: the titles, the review texts and the author, movie and review fakers. Change the guard at the top of `AddData` to `context.Cinemas.Any()`. + - **Presentation:** delete the author, movie and review files in `Tools/`, `Resources/` and `Prompts/`, and `AuthorResult.cs`, `MovieResult.cs`, `ReviewResult.cs` and `DeletedReview.cs` from `Models/`. Then change: + - `ResultMapper.cs`: keep the cinema mapping and the paging `ToResult`. + - `AppJsonSerializerContext.cs`: remove their `[JsonSerializable]` lines. + - `ItemIcons.cs`: remove `Movie`, `Author` and `Review`. + - `CompletionHandler.cs`: keep only the `cinemaId` case, and send every request to it. The prompt branch only existed for the sample prompts' title and name arguments, and `describe_cinema`'s `cinemaId` completes the same way as the resource's. + - `ToolErrorFilter.cs` and `ProtocolErrorFilter.cs`: the not found hint names the sample tools. Point it at `cinemas_search` instead. + - `ResourceLinkFilter.cs`: `GetName` falls back to a review's movie and author. Make the fallback the item's id: `GetText(item, "title") ?? GetText(item, "name") ?? GetText(item, "id")!`. + - `WebApplicationBuilderExtensions.cs`: rewrite `ServerInstructions` for what's left. + + ```csharp + options.ServerInstructions = """ + This server holds a list of cinemas. + Search first: cinemas_search finds cinemas by name and returns their ids, one page at a time, so pass + nextOffset back as the offset to continue rather than raising the limit. + cinemas_add adds a cinema. Calling it twice adds two, so search before you add. + Each cinema can also be read as a resource at cinemas://{id}, the describe_cinema prompt writes a + blurb for one, and server://version gives this server's version. + """; + ``` + + Keep at least one prompt. With none, the server stops offering `prompts/list` at all, and the icon test fails trying to list them. + + - **Migrations:** replace the history with one migration for your schema. With the old snapshot gone, the tool files the new one under a folder named after the namespace, so move it back beside the migration. Then add `// ` to the first line of `_InitialCreate.cs`, as in step 3. + + ```bash + rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + dotnet ef migrations add InitialCreate \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + mv src/Infrastructure/WorksOnMyMachine/Movies/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs \ + src/Infrastructure/Databases/MovieReviews/Migrations/ + rm -r src/Infrastructure/WorksOnMyMachine + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Delete `src/Presentation/movie-reviews.db` too, if you've run the API. It still holds the old tables, and the new migration history won't recognise it. + + - **Tests to delete:** + - `tests/Application.Tests.Unit/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `tests/Presentation.Tests.Unit/Tools/` and `tests/Presentation.Tests.Integration/Tools/`: `AuthorToolTests.cs`, `MovieToolTests.cs` and `ReviewToolTests.cs`. + - `EntityFrameworkMovieReviewsRepositoryTests.cs`: the `Authors`, `Movies` and `Reviews` regions. + - `MovieReviewsCollectionData.cs`: the author, movie and review rows. + - **Tests to change:** these test the template's plumbing, and they used the sample as their subject. Point them at cinemas. + + | Test | Change | + | --- | --- | + | `Common/Behaviours/LoggingBehaviourTests.cs` and `ValidationBehaviourTests.cs` | Use `CreateCinemaCommand`, `CreateCinemaValidator` and `Cinema` in place of `GetAuthorByIdQuery`, its validator and `Author`. The invalid case is an empty `Name`, so the expected property is `Name`. The logged message names `CreateCinemaCommand`. | + | `Common/Exceptions/NotFoundExceptionTests.cs` | Use `EntityType.Cinema` and a `Cinema`. The message becomes "The Cinema with the supplied id was not found." | + | `MovieReviewsConfigurationTests.cs` | Check a cinema loads, ignoring case: look up `"one"` and expect `"One"`. | + | `DependencyInjectionTests.cs` | Expect `ICinemasRepository`, 5 seeded cinemas and none when seeding is off. Use `GetCinemas` for the concurrency and same-sample-data checks. | + | `Completions/CompletionHandlerTests.cs` | Use a `cinemas://{cinemaId}` reference, and check a value too long to search returns nothing without sending a query. | + | `Errors/ToolErrorFilterTests.cs` and `ProtocolErrorFilterTests.cs` | Expect the new not found hint. | + | `Models/ResultMapperTests.cs` | Map a cinema, and keep the paging test, using cinemas. | + | `Tools/ToolListTests.cs` and `Prompts/PromptTests.cs` | Expect only the cinema tools and `describe_cinema`. | + | `Resources/ResourceTests.cs` | Expect only `cinemas://{cinemaId}` and the version resource. | + | `Tools/ResourceLinkTests.cs` and `Completions/CompletionTests.cs` | Use cinemas for every case: links from `cinemas_search` and `cinemas_add`, no link for a failed call, and completion by name, by id prefix, with an empty value and with no match. | + | `Telemetry/TelemetryTests.cs` | Call `cinemas_search` and expect a `GetCinemasQuery` span. | + +7. **Check it the way CI will** (`coverage`). + + ```bash + dotnet build + dotnet format --verify-no-changes + dotnet test -m:1 \ + -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ + -p:CoverletOutputFormat=opencover%2cjson \ + -p:ExcludeByFile="**/Program.cs%2c**/Migrations/*.cs%2c**/obj/**/*.cs" \ + -p:ExcludeByAttribute="GeneratedCodeAttribute" + ``` + + The last table it prints is the merged total. Ours came out at 99.8%. If yours is under 95%, `coverage` says what to do about it, and it isn't "lower the number". + + + +## What's still called "movie reviews" + +The database plumbing keeps its sample name: the `Databases/MovieReviews` folder, `MovieReviewsDbContext`, `EntityFrameworkMovieReviewsRepository`, the `MovieReviews` connection string and the `movie-reviews.db` file. They all work as they are. Everything in `src/Infrastructure` is `internal`, so renaming them later is a refactor that never reaches Application or Presentation. Do it when the name starts to bother you, and change the connection string in every `appsettings.*.json` and in `docker-compose.yml` at the same time. diff --git a/src/content/docs/minimalapi/getting-started.mdx b/src/content/docs/minimalapi/getting-started.mdx index c970d0e..cbc833a 100644 --- a/src/content/docs/minimalapi/getting-started.mdx +++ b/src/content/docs/minimalapi/getting-started.mdx @@ -97,6 +97,7 @@ Hit a snag? Check [Troubleshooting](/minimalapi/reference/troubleshooting/). + diff --git a/src/content/docs/minimalapi/guides/make-it-yours.mdx b/src/content/docs/minimalapi/guides/make-it-yours.mdx new file mode 100644 index 0000000..0ab9967 --- /dev/null +++ b/src/content/docs/minimalapi/guides/make-it-yours.mdx @@ -0,0 +1,538 @@ +--- +title: "Make it yours" +description: "Add your own entity end to end, then clear out the sample movies, authors and reviews without dropping below the 95% coverage gate." +sidebar: + order: 2 +--- + +import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; + +Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, OpenAPI and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. + +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with `GET /api/cinemas` to list and search them and `POST /api/cinemas` to create one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 79 passing tests and 99.7% line coverage, and we ran it a second time from the page alone to be sure. + + + +The code below assumes you named the solution `WorksOnMyMachine.Movies`. Swap in your own name wherever you see it. + + + +1. **Add the entity and use cases to `src/Application`** (`add-a-use-case`). + + The entity, the repository interface, one query and one command, each with a validator. Handlers and validators are `public`, like the samples, so Mediator's source generator and the tests can see them. + + + - src/Application/Cinemas/ + - Entities/Cinema.cs + - ICinemasRepository.cs + - Queries/GetCinemas/ + - GetCinemasQuery.cs + - GetCinemasHandler.cs + - GetCinemasValidator.cs + - Commands/CreateCinema/ + - CreateCinemaCommand.cs + - CreateCinemaHandler.cs + - CreateCinemaValidator.cs + + + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; + + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); + ``` + + ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas; + + using Common.Paging; + using Entities; + + public interface ICinemasRepository + { + public Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken); + + public Task CreateCinema(string name, CancellationToken cancellationToken); + } + ``` + + The query reuses the shared paging and search rules, so `Common/Paging` stays covered once the sample queries are gone. + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + /// + /// Lists cinemas one page at a time, optionally only those whose name contains a search term. + /// + public class GetCinemasQuery : IRequest> + { + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + public string? Query { get; init; } + + /// The number of results to skip, 0 for the first page. + public int Offset { get; init; } + + /// The largest number of results to return, from 1 to 50. + public int Limit { get; init; } = Paging.DefaultLimit; + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + public class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + { + public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) + { + return await repository.GetCinemas(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using FluentValidation; + + public class GetCinemasValidator : AbstractValidator + { + public GetCinemasValidator() + { + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); + _ = this.RuleFor(r => r.Offset).ValidOffset(); + _ = this.RuleFor(r => r.Limit).ValidLimit(); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaCommand : IRequest + { + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) + { + return await repository.CreateCinema(request.Name, cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using FluentValidation; + + public class CreateCinemaValidator : AbstractValidator + { + public CreateCinemaValidator() + { + _ = this.RuleFor(r => r.Name).NotEmpty().WithMessage("A cinema needs a name."); + _ = this.RuleFor(r => r.Name).MaximumLength(100).WithMessage("A cinema name can't be longer than 100 characters."); + } + } + ``` + +2. **Store and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly`). + + Add the database model and its configuration beside the sample ones. Every model inherits `Entity`, which brings the id and the created and modified dates, and every configuration inherits `EntityConfiguration`, which sets them up. + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; + + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal record Cinema : Entity + { + public required string Name { get; init; } + } + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Models; + + internal class CinemaConfiguration : EntityConfiguration + { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(c => c.Name).HasMaxLength(100).UseCollation(Collations.NoCase); + _ = builder.HasIndex(c => c.Name); + } + } + ``` + + Then wire it in: + + - **`MovieReviewsDbContext.cs`:** add `public DbSet Cinemas => this.Set();` beside the other sets. + - **`Mapping/MovieReviewsMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using InfraCinema = Models.Cinema;` with the other aliases, and this method: + + ```csharp + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + public partial AppCinema ToDomain(InfraCinema cinema); + ``` + + - **`EntityFrameworkMovieReviewsRepository.cs`:** add `using Application.Cinemas;` and `using ApplicationCinema = Application.Cinemas.Entities.Cinema;`, add `ICinemasRepository` to the interfaces the class implements, and add a `Cinemas` region. The search reuses the repository's own `ContainsPattern` and `EscapeCharacter`, so a `%` or `_` in a search term matches itself, the same as for movies. + + ```csharp + #region Cinemas + + public async Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken) + { + var cinemas = this.context.Cinemas.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); + + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, pattern, EscapeCharacter)); + } + + var totalCount = await cinemas.CountAsync(cancellationToken); + + var result = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .ToListAsync(cancellationToken); + + return new([.. result.Select(this.mapper.ToDomain)], page.Offset, page.Limit, totalCount); + } + + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + var cinema = new Cinema + { + Name = name, + DateCreated = now, + DateModified = now + }; + + _ = this.context.Add(cinema); + + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + #endregion Cinemas + ``` + + - **`src/Infrastructure/DependencyInjection.cs`:** add `using Application.Cinemas;` and register the interface against the same repository, like the others: + + ```csharp + _ = services.AddScoped(x => + x.GetRequiredService()); + ``` + + - **`Extensions/MovieReviewsDbContextExtensions.cs`** (`sample-data`): give `local` and `docker` some cinemas to look at. Add this to `AddData` before `SaveChanges`: + + ```csharp + var cinemas = new Faker() + .UseSeed(Seed) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => $"{f.Address.City()} Picture House") + .RuleFor(c => c.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) + .RuleFor(c => c.DateModified, f => f.Date.Past(refDate: ReferenceDate)) + .Generate(5); + + context.AddRange(cinemas); + ``` + +3. **Add a migration** (`database-migrations`). + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add AddCinemas \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Without `--namespace` the migration lands in a namespace of its own. The `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// ` the first line of the new `_AddCinemas.cs`, as it is in the other migrations, so the analysers leave generated code alone. + +4. **Put it on endpoints in `src/Presentation`** (`expose-a-use-case` and `xml-docs-and-openapi`). + + The XML comments aren't decoration. They become the OpenAPI description, and a test fails if an operation or schema has none. + + ```csharp title="src/Presentation/Requests/CreateCinemaRequest.cs" + namespace WorksOnMyMachine.Movies.Presentation.Requests; + + /// + /// The details needed to create a cinema. + /// + public class CreateCinemaRequest + { + /// + /// The name of the cinema, up to 100 characters. + /// + /// The Astor + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Presentation/Endpoints/CinemaEndpoints.cs" + namespace WorksOnMyMachine.Movies.Presentation.Endpoints; + + using Application.Common.Paging; + using Mediator; + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.HttpResults; + using Microsoft.AspNetCore.Mvc; + using Presentation.Requests; + using Commands = Application.Cinemas.Commands; + using Entities = Application.Cinemas.Entities; + using Queries = Application.Cinemas.Queries; + + public static class CinemaEndpoints + { + public static WebApplication MapCinemaEndpoints(this WebApplication app) + { + var root = app.MapGroup("/api/cinemas") + .WithTags("Cinemas"); + + _ = root.MapGet("/", GetCinemas) + .WithName(nameof(GetCinemas)) + .Produces>() + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status500InternalServerError); + + _ = root.MapPost("/", CreateCinema) + .WithName(nameof(CreateCinema)) + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status500InternalServerError); + + return app; + } + + /// + /// List Cinemas + /// + /// + /// Returns one page of cinemas, ordered by name. Add a search term to return only the cinemas whose name + /// contains it, ignoring case. To fetch the next page, add the limit to the offset. + /// + /// Sample request: + /// + /// GET /api/cinemas?query=astor&offset=0&limit=10 + /// + /// The words to look for in the name, ignoring case. Leave it out to list every cinema. + /// The number of cinemas to skip, 0 for the first page. + /// The largest number of cinemas to return, from 1 to 50. + /// One page of Cinemas. + /// The search term, offset or limit is not valid. + /// An unexpected error occurred. + public static async Task>> GetCinemas( + [FromQuery] string? query, + [FromServices] ISender sender, + CancellationToken cancellationToken, + [FromQuery] int offset = 0, + [FromQuery] int limit = Paging.DefaultLimit) + { + return TypedResults.Ok(await sender.Send(new Queries.GetCinemas.GetCinemasQuery + { + Query = query, + Offset = offset, + Limit = limit + }, cancellationToken)); + } + + /// + /// Create a Cinema + /// + /// + /// Creates a cinema. The name can't be empty or longer than 100 characters. + /// + /// Sample request: + /// + /// POST /api/cinemas + /// { + /// "name": "The Astor" + /// } + /// + /// The cinema to create. + /// The Cinema that was created. + /// The request is not valid, for example the name is empty. + /// An unexpected error occurred. + public static async Task> CreateCinema( + [FromBody] CreateCinemaRequest request, + [FromServices] ISender sender, + CancellationToken cancellationToken) + { + var response = await sender.Send(new Commands.CreateCinema.CreateCinemaCommand + { + Name = request.Name + }, cancellationToken); + + return TypedResults.Created((string?)null, response); + } + } + ``` + + There's no endpoint that gets one cinema by its id yet, so the 201 has no `Location` header. Add a `GetCinemaById` and switch to `TypedResults.CreatedAtRoute`, as `CreateReview` does, when you need one. + + Then register it: + + - **`Serialization/AppJsonSerializerContext.cs`** (`json-serialization`): add `using Application.Cinemas.Entities;`, then `[JsonSerializable(typeof(Cinema))]`, `[JsonSerializable(typeof(Page))]` and `[JsonSerializable(typeof(CreateCinemaRequest))]`. + - **`OpenApi/DocumentInfoTransformer.cs`:** add `CreateTag("Cinemas", "Lookup all Cinemas, search them by name, or create new ones"),` to the tags. + - **`Extensions/WebApplicationExtensions.cs`:** add `_ = app.MapCinemaEndpoints();` beside the other `Map*Endpoints()` calls. + - **`--include-auth`:** if you created the solution with it, add `_ = root.RequireAuthorization();` after `WithTags`, as the other endpoint groups do. + - **`Presentation.http`:** add a request for each endpoint, including one that fails validation. + +5. **Test the new slice** (`test-design`, `unit-tests` and `integration-tests`). + + Each new test has a sample test that already does the same job for movies or reviews. Copy it and rename it, and you have the shape, the names and the house style without having to think about them. + + | New test | Copy from | + | --- | --- | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasHandlerTests.cs` | `Movies/Queries/GetMovies/GetMoviesHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasValidatorTests.cs` | `Movies/Queries/GetMovies/GetMoviesValidatorTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaHandlerTests.cs` | `Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaValidatorTests.cs` | `Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs` | + | A `Cinemas` region in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs` | The `Movies` region: search, paging and create | + | `tests/Presentation.Tests.Unit/Endpoints/CinemaEndpointTests.cs` | `MovieEndpointTests.cs` and `ReviewEndpointTests.cs` | + | `tests/Presentation.Tests.Integration/Endpoints/CinemaEndpointTests.cs` | `MovieEndpointTests.cs` and `ReviewEndpointTests.cs` | + + The repository tests need some cinemas. Add three to `AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs`, named the same way as the sample rows: + + ```csharp + List cinemas = + [ + new() { Id = Guid.NewGuid(), Name = "One" }, + new() { Id = Guid.NewGuid(), Name = "Two" }, + new() { Id = Guid.NewGuid(), Name = "Three" } + ]; + + context.Cinemas.AddRange(cinemas); + ``` + + Two existing tests in `tests/Presentation.Tests.Integration/OpenApi/OpenApiDocumentTests.cs` fail as soon as the endpoints exist. They're doing their job: they pin down exactly what the API publishes. + + - **`GetOpenApiDocument_ShouldDescribe_Tags`:** add `"Cinemas"` to the expected tags. + - **`GetOpenApiDocument_ShouldDescribe_EveryOperation`:** add 2 to the operation count, one for each new endpoint. + - **`GetOpenApiDocument_ShouldDescribe_Schema`:** add `[InlineData("Cinema")]`, `[InlineData("PageOfCinema")]` and `[InlineData("CreateCinemaRequest")]`, so the new schemas have to be documented too. + + ```bash + dotnet build + dotnet test + dotnet format --verify-no-changes + ``` + + Both domains now run side by side. Stop here if you're keeping the sample for reference, which is a perfectly respectable choice. + +6. **Take out the sample movies, authors and reviews** (`sample-data` and `database-migrations`). + + This step is mostly deleting, but the template's own tests use the sample as their test subject, so a few of them change instead of going. Work through it layer by layer and build as you go. The compiler finds what's left faster than any list. + + - **Application:** delete `src/Application/Authors/`, `Movies/` and `Reviews/`. In `Common/Enums/EntityType.cs`, replace `Author`, `Movie` and `Review` with `Cinema`. `NotFoundException` uses it, and you'll want it the day you add `GetCinemaById`. + - **Infrastructure:** delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/`, and the matching `*Configuration.cs` files. Then remove from: + - `MovieReviewsDbContext.cs`: their three `DbSet` properties. + - `MovieReviewsMapper.cs`: their aliases and mapping methods. + - `EntityFrameworkMovieReviewsRepository.cs`: the `Authors`, `Movies`, `Reviews` and `Helpers` regions, their `using` lines and aliases, and their three interfaces. + - `DependencyInjection.cs`: their three registrations and `using` lines. + - `MovieReviewsDbContextExtensions.cs`: the titles, the review texts and the author, movie and review fakers. Change the guard at the top of `AddData` to `context.Cinemas.Any()`. + - **Presentation:** delete `AuthorEndpoints.cs`, `MovieEndpoints.cs`, `ReviewEndpoints.cs`, `CreateReviewRequest.cs` and `UpdateReviewRequest.cs`. Then remove from: + - `AppJsonSerializerContext.cs`: their `[JsonSerializable]` lines and `using` lines. + - `DocumentInfoTransformer.cs`: their tags. + - `WebApplicationExtensions.cs`: their `Map*Endpoints()` calls. + - `Presentation.http`: the sample ids and requests. + - **Migrations:** replace the history with one migration for your schema. With the old snapshot gone, the tool files the new one under a folder named after the namespace, so move it back beside the migration. Then add `// ` to the first line of `_InitialCreate.cs`, as in step 3. + + ```bash + rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + dotnet ef migrations add InitialCreate \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + mv src/Infrastructure/WorksOnMyMachine/Movies/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs \ + src/Infrastructure/Databases/MovieReviews/Migrations/ + rm -r src/Infrastructure/WorksOnMyMachine + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Delete `src/Presentation/movie-reviews.db` too, if you've run the API. It still holds the old tables, and the new migration history won't recognise it. + + - **Tests to delete:** + - `tests/Application.Tests.Unit/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `tests/Presentation.Tests.Unit/Endpoints/` and `tests/Presentation.Tests.Integration/Endpoints/`: `AuthorEndpointTests.cs`, `MovieEndpointTests.cs` and `ReviewEndpointTests.cs`. + - `EntityFrameworkMovieReviewsRepositoryTests.cs`: the `Authors`, `Movies` and `Reviews` regions. + - `MovieReviewsCollectionData.cs`: the author, movie and review rows. + - **Tests to change:** these test the template's plumbing, and they used the sample as their subject. Point them at cinemas. + + | Test | Change | + | --- | --- | + | `Common/Behaviours/LoggingBehaviourTests.cs` and `ValidationBehaviourTests.cs` | Use `CreateCinemaCommand`, `CreateCinemaValidator` and `Cinema` in place of `GetAuthorByIdQuery`, its validator and `Author`. The invalid case is an empty `Name`, so the expected property is `Name`. The logged message names `CreateCinemaCommand`. | + | `Common/Exceptions/NotFoundExceptionTests.cs` | Use `EntityType.Cinema` and a `Cinema`. The message becomes "The Cinema with the supplied id was not found." | + | `MovieReviewsConfigurationTests.cs` | Check a cinema loads, ignoring case: look up `"one"` and expect `"One"`. | + | `DependencyInjectionTests.cs` | Expect `ICinemasRepository`, 5 seeded cinemas and none when seeding is off. Use `GetCinemas` for the concurrency and same-sample-data checks. | + | `Telemetry/TelemetryTests.cs` | Call `/api/cinemas?limit=5` and expect a `GetCinemasQuery` span. | + | `OpenApi/OpenApiDocumentTests.cs` | Expect the tags `Cinemas` and `Version`, 3 operations, and the schemas `Cinema`, `PageOfCinema`, `CreateCinemaRequest` and `VersionInfo`. | + | `AuthenticationTests.cs`, only with `--include-auth` | Point its requests at `/api/cinemas`. | + +7. **Check it the way CI will** (`coverage`). + + ```bash + dotnet build + dotnet format --verify-no-changes + dotnet test -m:1 \ + -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ + -p:CoverletOutputFormat=opencover%2cjson \ + -p:ExcludeByFile="**/Program.cs%2c**/Migrations/*.cs%2c**/obj/**/*.cs" \ + -p:ExcludeByAttribute="GeneratedCodeAttribute" + ``` + + The last table it prints is the merged total. Ours came out at 99.7%. If yours is under 95%, `coverage` says what to do about it, and it isn't "lower the number". + + + +## What's still called "movie reviews" + +The database plumbing keeps its sample name: the `Databases/MovieReviews` folder, `MovieReviewsDbContext`, `EntityFrameworkMovieReviewsRepository`, the `MovieReviews` connection string and the `movie-reviews.db` file. They all work as they are. Everything in `src/Infrastructure` is `internal`, so renaming them later is a refactor that never reaches Application or Presentation. Do it when the name starts to bother you, and change the connection string in every `appsettings.*.json` and in `docker-compose.yml` at the same time. diff --git a/src/content/docs/webapi/getting-started.mdx b/src/content/docs/webapi/getting-started.mdx index 2207a9e..ec51e6f 100644 --- a/src/content/docs/webapi/getting-started.mdx +++ b/src/content/docs/webapi/getting-started.mdx @@ -97,6 +97,7 @@ Hit a snag? Check [Troubleshooting](/webapi/reference/troubleshooting/). + diff --git a/src/content/docs/webapi/guides/make-it-yours.mdx b/src/content/docs/webapi/guides/make-it-yours.mdx new file mode 100644 index 0000000..91c47c0 --- /dev/null +++ b/src/content/docs/webapi/guides/make-it-yours.mdx @@ -0,0 +1,519 @@ +--- +title: "Make it yours" +description: "Add your own entity end to end, then clear out the sample movies, authors and reviews without dropping below the 95% coverage gate." +sidebar: + order: 2 +--- + +import { Steps, Aside, FileTree } from '@astrojs/starlight/components'; + +Sooner or later you'll want to evict *Deep Space Gardeners* and move your own domain in. The tempting move is to delete `Movies`, `Authors` and `Reviews` first. Don't. Half the tests stop compiling, and the shared plumbing (paging, validation, logging, OpenAPI and telemetry) suddenly has nothing to exercise it, so coverage falls through the floor. + +Go the other way round. Build your first slice beside the sample domain, prove it works end to end, then take the movies out. This guide adds cinemas, with `GET /api/cinemas` to list and search them and `POST /api/cinemas` to create one, and finishes with a solution that has no movies left in it. We ran every step on a fresh solution: it ends with 76 passing tests and 99.7% line coverage. + + + +The code below assumes you named the solution `WorksOnMyMachine.Movies`. Swap in your own name wherever you see it. + + + +1. **Add the entity and use cases to `src/Application`** (`add-a-use-case`). + + The entity, the repository interface, one query and one command, each with a validator. Handlers and validators are `public`, like the samples, so Mediator's source generator and the tests can see them. + + + - src/Application/Cinemas/ + - Entities/Cinema.cs + - ICinemasRepository.cs + - Queries/GetCinemas/ + - GetCinemasQuery.cs + - GetCinemasHandler.cs + - GetCinemasValidator.cs + - Commands/CreateCinema/ + - CreateCinemaCommand.cs + - CreateCinemaHandler.cs + - CreateCinemaValidator.cs + + + ```csharp title="src/Application/Cinemas/Entities/Cinema.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Entities; + + /// + /// A cinema that shows movies. + /// + /// The unique identifier of the cinema. + /// The name of the cinema. + public record Cinema(Guid Id, string Name); + ``` + + ```csharp title="src/Application/Cinemas/ICinemasRepository.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas; + + using Common.Paging; + using Entities; + + public interface ICinemasRepository + { + public Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken); + + public Task CreateCinema(string name, CancellationToken cancellationToken); + } + ``` + + The query reuses the shared paging and search rules, so `Common/Paging` stays covered once the sample queries are gone. + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasQuery.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + /// + /// Lists cinemas one page at a time, optionally only those whose name contains a search term. + /// + public class GetCinemasQuery : IRequest> + { + /// The words to look for in the name, ignoring case. Leave empty to list every cinema. + public string? Query { get; init; } + + /// The number of results to skip, 0 for the first page. + public int Offset { get; init; } + + /// The largest number of results to return, from 1 to 50. + public int Limit { get; init; } = Paging.DefaultLimit; + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using Entities; + using Mediator; + + public class GetCinemasHandler(ICinemasRepository repository) : IRequestHandler> + { + public async ValueTask> Handle(GetCinemasQuery request, CancellationToken cancellationToken) + { + return await repository.GetCinemas(request.Query, new PageRequest(request.Offset, request.Limit), cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Queries/GetCinemas/GetCinemasValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Queries.GetCinemas; + + using Common.Paging; + using FluentValidation; + + public class GetCinemasValidator : AbstractValidator + { + public GetCinemasValidator() + { + _ = this.RuleFor(r => r.Query).ValidSearchTerm(); + _ = this.RuleFor(r => r.Offset).ValidOffset(); + _ = this.RuleFor(r => r.Limit).ValidLimit(); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaCommand.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaCommand : IRequest + { + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaHandler.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using Entities; + using Mediator; + + public class CreateCinemaHandler(ICinemasRepository repository) : IRequestHandler + { + public async ValueTask Handle(CreateCinemaCommand request, CancellationToken cancellationToken) + { + return await repository.CreateCinema(request.Name, cancellationToken); + } + } + ``` + + ```csharp title="src/Application/Cinemas/Commands/CreateCinema/CreateCinemaValidator.cs" + namespace WorksOnMyMachine.Movies.Application.Cinemas.Commands.CreateCinema; + + using FluentValidation; + + public class CreateCinemaValidator : AbstractValidator + { + public CreateCinemaValidator() + { + _ = this.RuleFor(r => r.Name).NotEmpty().WithMessage("A cinema needs a name."); + _ = this.RuleFor(r => r.Name).MaximumLength(100).WithMessage("A cinema name can't be longer than 100 characters."); + } + } + ``` + +2. **Store and map it in `src/Infrastructure`** (`data-access-ef-core` and `mapping-with-mapperly`). + + Add the database model and its configuration beside the sample ones. Every model inherits `Entity`, which brings the id and the created and modified dates, and every configuration inherits `EntityConfiguration`, which sets them up. + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Models/Cinema.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Models; + + using System.Diagnostics.CodeAnalysis; + + [ExcludeFromCodeCoverage] + internal record Cinema : Entity + { + public required string Name { get; init; } + } + ``` + + ```csharp title="src/Infrastructure/Databases/MovieReviews/Configuration/CinemaConfiguration.cs" + namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Configuration; + + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Models; + + internal class CinemaConfiguration : EntityConfiguration + { + public override void Configure(EntityTypeBuilder builder) + { + base.Configure(builder); + + _ = builder.Property(c => c.Name).HasMaxLength(100).UseCollation(Collations.NoCase); + _ = builder.HasIndex(c => c.Name); + } + } + ``` + + Then wire it in: + + - **`MovieReviewsDbContext.cs`:** add `public DbSet Cinemas => this.Set();` beside the other sets. + - **`Mapping/MovieReviewsMapper.cs`:** add `using AppCinema = Application.Cinemas.Entities.Cinema;` and `using InfraCinema = Models.Cinema;` with the other aliases, and this method: + + ```csharp + [MapperIgnoreSource(nameof(InfraCinema.DateCreated))] + [MapperIgnoreSource(nameof(InfraCinema.DateModified))] + public partial AppCinema ToDomain(InfraCinema cinema); + ``` + + - **`EntityFrameworkMovieReviewsRepository.cs`:** add `using Application.Cinemas;` and `using ApplicationCinema = Application.Cinemas.Entities.Cinema;`, add `ICinemasRepository` to the interfaces the class implements, and add a `Cinemas` region. The search reuses the repository's own `ContainsPattern` and `EscapeCharacter`, so a `%` or `_` in a search term matches itself, the same as for movies. + + ```csharp + #region Cinemas + + public async Task> GetCinemas( + string? query, + PageRequest page, + CancellationToken cancellationToken) + { + var cinemas = this.context.Cinemas.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var pattern = ContainsPattern(query); + + cinemas = cinemas.Where(c => EF.Functions.Like(c.Name, pattern, EscapeCharacter)); + } + + var totalCount = await cinemas.CountAsync(cancellationToken); + + var result = await cinemas + .OrderBy(c => c.Name) + .ThenBy(c => c.Id) + .Skip(page.Offset) + .Take(page.Limit) + .ToListAsync(cancellationToken); + + return new([.. result.Select(this.mapper.ToDomain)], page.Offset, page.Limit, totalCount); + } + + public async Task CreateCinema(string name, CancellationToken cancellationToken) + { + var now = this.timeProvider.GetUtcNow().UtcDateTime; + + var cinema = new Cinema + { + Name = name, + DateCreated = now, + DateModified = now + }; + + _ = this.context.Add(cinema); + + _ = await this.context.SaveChangesAsync(cancellationToken); + + return this.mapper.ToDomain(cinema); + } + + #endregion Cinemas + ``` + + - **`src/Infrastructure/DependencyInjection.cs`:** add `using Application.Cinemas;` and register the interface against the same repository, like the others: + + ```csharp + _ = services.AddScoped(x => + x.GetRequiredService()); + ``` + + - **`Extensions/MovieReviewsDbContextExtensions.cs`** (`sample-data`): give `local` and `docker` some cinemas to look at. Add this to `AddData` before `SaveChanges`: + + ```csharp + var cinemas = new Faker() + .UseSeed(Seed) + .RuleFor(c => c.Id, f => f.Random.Guid()) + .RuleFor(c => c.Name, f => $"{f.Address.City()} Picture House") + .RuleFor(c => c.DateCreated, f => f.Date.Past(refDate: ReferenceDate)) + .RuleFor(c => c.DateModified, f => f.Date.Past(refDate: ReferenceDate)) + .Generate(5); + + context.AddRange(cinemas); + ``` + +3. **Add a migration** (`database-migrations`). + + ```bash + dotnet tool install --global dotnet-ef + dotnet ef migrations add AddCinemas \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Without `--namespace` the migration lands in a namespace of its own. The `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// ` the first line of the new `_AddCinemas.cs`, as it is in the other migrations, so the analysers leave generated code alone. + +4. **Put it on a controller in `src/Presentation`** (`expose-a-use-case` and `xml-docs-and-openapi`). + + The XML comments aren't decoration. They become the OpenAPI description, and a test fails if an operation or schema has none. Each controller gets its own folder under `Controllers/`, with its request models beside it. + + ```csharp title="src/Presentation/Controllers/Cinemas/Requests/CreateCinemaRequest.cs" + namespace WorksOnMyMachine.Movies.Presentation.Controllers.Cinemas.Requests; + + /// + /// The details needed to create a cinema. + /// + public class CreateCinemaRequest + { + /// + /// The name of the cinema, up to 100 characters. + /// + /// The Astor + public string Name { get; init; } = string.Empty; + } + ``` + + ```csharp title="src/Presentation/Controllers/Cinemas/CinemasController.cs" + namespace WorksOnMyMachine.Movies.Presentation.Controllers.Cinemas; + + using Application.Common.Paging; + using Common; + using Mediator; + using Microsoft.AspNetCore.Mvc; + using Requests; + using Commands = Application.Cinemas.Commands; + using Entities = Application.Cinemas.Entities; + using Queries = Application.Cinemas.Queries; + + [Route("api/cinemas")] + public class CinemasController(ISender sender) : ApiControllerBase(sender) + { + /// + /// List Cinemas + /// + /// + /// Returns one page of cinemas, ordered by name. Add a search term to return only the cinemas whose name + /// contains it, ignoring case. To fetch the next page, add the limit to the offset. + /// + /// Sample request: + /// + /// GET /api/cinemas?query=astor&offset=0&limit=10 + /// + /// The words to look for in the name, ignoring case. Leave it out to list every cinema. + /// The number of cinemas to skip, 0 for the first page. + /// The largest number of cinemas to return, from 1 to 50. + /// One page of Cinemas. + /// The search term, offset or limit is not valid. + /// An unexpected error occurred. + [HttpGet] + [EndpointName(nameof(GetCinemas))] + [ProducesResponseType(typeof(Page), StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError, "application/problem+json")] + public async Task>> GetCinemas( + CancellationToken cancellationToken, + [FromQuery] string? query = null, + [FromQuery] int offset = 0, + [FromQuery] int limit = Paging.DefaultLimit) + { + return this.Ok(await this.Sender.Send(new Queries.GetCinemas.GetCinemasQuery + { + Query = query, + Offset = offset, + Limit = limit + }, cancellationToken)); + } + + /// + /// Create a Cinema + /// + /// + /// Creates a cinema. The name can't be empty or longer than 100 characters. + /// + /// Sample request: + /// + /// POST /api/cinemas + /// { + /// "name": "The Astor" + /// } + /// + /// The cinema to create. + /// The Cinema that was created. + /// The request is not valid, for example the name is empty. + /// An unexpected error occurred. + [HttpPost] + [EndpointName(nameof(CreateCinema))] + [ProducesResponseType(typeof(Entities.Cinema), StatusCodes.Status201Created, "application/json")] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError, "application/problem+json")] + public async Task> CreateCinema(CreateCinemaRequest request, CancellationToken cancellationToken) + { + var response = await this.Sender.Send(new Commands.CreateCinema.CreateCinemaCommand + { + Name = request.Name + }, cancellationToken); + + return this.StatusCode(StatusCodes.Status201Created, response); + } + } + ``` + + There's no action that gets one cinema by its id yet, so the 201 has no `Location` header. Add a `GetCinemaById` and switch to `this.CreatedAtAction`, as `ReviewsController.CreateReview` does, when you need one. + + Then register it: + + - **`OpenApi/DocumentInfoTransformer.cs`:** add `CreateTag("Cinemas", "Lookup all Cinemas, search them by name, or create new ones"),` to the tags. + - **`Presentation.http`:** add a request for each action, including one that fails validation. + + That's all. MVC finds the controller by itself, the JSON options already cover the new types, and with `--include-auth` the `[Authorize]` on `ApiControllerBase` protects it. + +5. **Test the new slice** (`test-design`, `unit-tests` and `integration-tests`). + + Each new test has a sample test that already does the same job for movies or reviews. Copy it and rename it, and you have the shape, the names and the house style without having to think about them. + + | New test | Copy from | + | --- | --- | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasHandlerTests.cs` | `Movies/Queries/GetMovies/GetMoviesHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Queries/GetCinemas/GetCinemasValidatorTests.cs` | `Movies/Queries/GetMovies/GetMoviesValidatorTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaHandlerTests.cs` | `Reviews/Commands/CreateReview/CreateReviewHandlerTests.cs` | + | `tests/Application.Tests.Unit/Cinemas/Commands/CreateCinema/CreateCinemaValidatorTests.cs` | `Reviews/Commands/CreateReview/CreateReviewValidatorTests.cs` | + | A `Cinemas` region in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/EntityFrameworkMovieReviewsRepositoryTests.cs` | The `Movies` region: search, paging and create | + | `tests/Presentation.Tests.Integration/Controllers/CinemaEndpointTests.cs` | `MovieEndpointTests.cs` and `ReviewEndpointTests.cs` | + + The repository tests need some cinemas. Add three to `AddTestData` in `tests/Infrastructure.Tests.Integration/Databases/MovieReviews/Extensions/MovieReviewsCollectionData.cs`, named the same way as the sample rows: + + ```csharp + List cinemas = + [ + new() { Id = Guid.NewGuid(), Name = "One" }, + new() { Id = Guid.NewGuid(), Name = "Two" }, + new() { Id = Guid.NewGuid(), Name = "Three" } + ]; + + context.Cinemas.AddRange(cinemas); + ``` + + Two existing tests in `tests/Presentation.Tests.Integration/OpenApi/OpenApiDocumentTests.cs` fail as soon as the endpoints exist. They're doing their job: they pin down exactly what the API publishes. + + - **`GetOpenApiDocument_ShouldDescribe_Tags`:** add `"Cinemas"` to the expected tags. + - **`GetOpenApiDocument_ShouldDescribe_EveryOperation`:** add 2 to the operation count, one for each new endpoint. + - **`GetOpenApiDocument_ShouldDescribe_Schema`:** add `[InlineData("Cinema")]`, `[InlineData("PageOfCinema")]` and `[InlineData("CreateCinemaRequest")]`, so the new schemas have to be documented too. + + ```bash + dotnet build + dotnet test + dotnet format --verify-no-changes + ``` + + Both domains now run side by side. Stop here if you're keeping the sample for reference, which is a perfectly respectable choice. + +6. **Take out the sample movies, authors and reviews** (`sample-data` and `database-migrations`). + + This step is mostly deleting, but the template's own tests use the sample as their test subject, so a few of them change instead of going. Work through it layer by layer and build as you go. The compiler finds what's left faster than any list. + + - **Application:** delete `src/Application/Authors/`, `Movies/` and `Reviews/`. In `Common/Enums/EntityType.cs`, replace `Author`, `Movie` and `Review` with `Cinema`. `NotFoundException` uses it, and you'll want it the day you add `GetCinemaById`. + - **Infrastructure:** delete `Author.cs`, `Movie.cs` and `Review.cs` from `Models/`, and the matching `*Configuration.cs` files. Then remove from: + - `MovieReviewsDbContext.cs`: their three `DbSet` properties. + - `MovieReviewsMapper.cs`: their aliases and mapping methods. + - `EntityFrameworkMovieReviewsRepository.cs`: the `Authors`, `Movies`, `Reviews` and `Helpers` regions, their `using` lines and aliases, and their three interfaces. + - `DependencyInjection.cs`: their three registrations and `using` lines. + - `MovieReviewsDbContextExtensions.cs`: the titles, the review texts and the author, movie and review fakers. Change the guard at the top of `AddData` to `context.Cinemas.Any()`. + - **Presentation:** delete `Controllers/Authors/`, `Controllers/Movies/` and `Controllers/Reviews/`. Then remove from: + - `DocumentInfoTransformer.cs`: their tags. + - `Presentation.http`: the sample ids and requests. + - **Migrations:** replace the history with one migration for your schema. With the old snapshot gone, the tool files the new one under a folder named after the namespace, so move it back beside the migration. Then add `// ` to the first line of `_InitialCreate.cs`, as in step 3. + + ```bash + rm src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + dotnet ef migrations add InitialCreate \ + --project ./src/Infrastructure \ + --output-dir Databases/MovieReviews/Migrations \ + --namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations + mv src/Infrastructure/WorksOnMyMachine/Movies/Infrastructure/Databases/MovieReviews/Migrations/MovieReviewsDbContextModelSnapshot.cs \ + src/Infrastructure/Databases/MovieReviews/Migrations/ + rm -r src/Infrastructure/WorksOnMyMachine + sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs + ``` + + Delete `src/Presentation/movie-reviews.db` too, if you've run the API. It still holds the old tables, and the new migration history won't recognise it. + + - **Tests to delete:** + - `tests/Application.Tests.Unit/`: the `Authors/`, `Movies/` and `Reviews/` folders. + - `tests/Presentation.Tests.Integration/Controllers/`: `AuthorEndpointTests.cs`, `MovieEndpointTests.cs` and `ReviewEndpointTests.cs`. + - `EntityFrameworkMovieReviewsRepositoryTests.cs`: the `Authors`, `Movies` and `Reviews` regions. + - `MovieReviewsCollectionData.cs`: the author, movie and review rows. + - **Tests to change:** these test the template's plumbing, and they used the sample as their subject. Point them at cinemas. + + | Test | Change | + | --- | --- | + | `Common/Behaviours/LoggingBehaviourTests.cs` and `ValidationBehaviourTests.cs` | Use `CreateCinemaCommand`, `CreateCinemaValidator` and `Cinema` in place of `GetAuthorByIdQuery`, its validator and `Author`. The invalid case is an empty `Name`, so the expected property is `Name`. The logged message names `CreateCinemaCommand`. | + | `Common/Exceptions/NotFoundExceptionTests.cs` | Use `EntityType.Cinema` and a `Cinema`. The message becomes "The Cinema with the supplied id was not found." | + | `MovieReviewsConfigurationTests.cs` | Check a cinema loads, ignoring case: look up `"one"` and expect `"One"`. | + | `DependencyInjectionTests.cs` | Expect `ICinemasRepository`, 5 seeded cinemas and none when seeding is off. Use `GetCinemas` for the concurrency and same-sample-data checks. | + | `Telemetry/TelemetryTests.cs` | Call `/api/cinemas?limit=5` and expect a `GetCinemasQuery` span. | + | `OpenApi/OpenApiDocumentTests.cs` | Expect the tags `Cinemas` and `Version`, 3 operations, and the schemas `Cinema`, `PageOfCinema`, `CreateCinemaRequest` and `VersionInfo`. | + | `Authentication/AuthenticationTests.cs`, only with `--include-auth` | Replace the movie, author and review routes with `/api/cinemas`. | + +7. **Check it the way CI will** (`coverage`). + + ```bash + dotnet build + dotnet format --verify-no-changes + dotnet test -m:1 \ + -p:CollectCoverage=true -p:CoverletOutput=../results/ -p:MergeWith="../results/coverage.json" \ + -p:CoverletOutputFormat=opencover%2cjson \ + -p:ExcludeByFile="**/Program.cs%2c**/Migrations/*.cs%2c**/obj/**/*.cs" \ + -p:ExcludeByAttribute="GeneratedCodeAttribute" + ``` + + The last table it prints is the merged total. Ours came out at 99.7%. If yours is under 95%, `coverage` says what to do about it, and it isn't "lower the number". + + + +## What's still called "movie reviews" + +The database plumbing keeps its sample name: the `Databases/MovieReviews` folder, `MovieReviewsDbContext`, `EntityFrameworkMovieReviewsRepository`, the `MovieReviews` connection string and the `movie-reviews.db` file. They all work as they are. Everything in `src/Infrastructure` is `internal`, so renaming them later is a refactor that never reaches Application or Presentation. Do it when the name starts to bother you, and change the connection string in every `appsettings.*.json` and in `docker-compose.yml` at the same time. diff --git a/src/styles/docs/theme.css b/src/styles/docs/theme.css index 55a8bdd..2d5215b 100644 --- a/src/styles/docs/theme.css +++ b/src/styles/docs/theme.css @@ -51,6 +51,14 @@ header.header { } } +/* The home pages have no sidebars, so they use the whole box with the front page's gutter and line up with it. */ +@media (min-width: 50rem) { + [data-has-hero]:not([data-has-sidebar]) { + --sl-content-pad-x: var(--cl-gutter); + --sl-content-width: calc(var(--cl-wrap) - 2 * var(--cl-gutter)); + } +} + /* Inside the box the two sidebars give up a little width so the writing keeps a readable measure. */ @media (min-width: 72rem) { :root {