Skip to content
Clean Web API
Lean Mean Clean Apps

Five templates, one search box. Everything in the docs is in here, including the bits we hoped you'd skim.

Try one of these

Results say which template they came from, so you won't end up reading about Lambda when you wanted GraphQL. Again.

Add an endpoint

This guide adds GET /api/movies/{id}/reviews, one page of the reviews written about a movie. The Application layer already has a GetReviewsQuery that filters by movie, so all the work happens in Presentation. That’s the point of the layers: new ways in, same rules underneath.

  1. Add the aliases for the review types at the top of src/Presentation/Controllers/Movies/MoviesController.cs. The file already aliases the movie types the same way.

    using Entities = Application.Movies.Entities;
    using Queries = Application.Movies.Queries;
    using ReviewEntities = Application.Reviews.Entities;
    using ReviewQueries = Application.Reviews.Queries;
  2. Add the action to the controller. ApiControllerBase already gives it [ApiController], [Authorize], [Consumes("application/json")] and the Sender, and the [Route("api/movies")] on the class gives the route its prefix. The XML comments aren’t decoration: they become the summary, description, parameter docs and response descriptions in the OpenAPI document, and Scalar shows every one of them.

    /// <summary>
    /// List the Reviews of a Movie
    /// </summary>
    /// <remarks>
    /// Returns one page of the reviews written about a movie, along with the author of each.
    ///
    /// Sample request:
    ///
    /// GET /api/movies/3fa85f64-5717-4562-b3fc-2c963f66afa6/reviews?offset=0&amp;limit=10
    /// </remarks>
    /// <param name="id">The unique identifier of the movie.</param>
    /// <param name="offset">The number of reviews to skip, 0 for the first page.</param>
    /// <param name="limit">The largest number of reviews to return, from 1 to 50.</param>
    /// <response code="200">One page of Reviews.</response>
    /// <response code="400">The Id, the offset or the limit is not valid.</response>
    /// <response code="500">An unexpected error occurred.</response>
    [HttpGet("{id:guid}/reviews")]
    [EndpointName(nameof(GetMovieReviews))]
    [ProducesResponseType(typeof(Page<ReviewEntities.Review>), StatusCodes.Status200OK, "application/json")]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError, "application/problem+json")]
    public async Task<ActionResult<Page<ReviewEntities.Review>>> GetMovieReviews(
    Guid id,
    CancellationToken cancellationToken,
    [FromQuery] int offset = 0,
    [FromQuery] int limit = Paging.DefaultLimit)
    {
    return this.Ok(await this.Sender.Send(new ReviewQueries.GetReviews.GetReviewsQuery
    {
    MovieId = id,
    Offset = offset,
    Limit = limit
    }, cancellationToken));
    }

    The content types on ProducesResponseType matter. Without them, the OpenAPI document lists an empty application/json body on the problem responses, and Scalar dutifully shows it.

  3. Test it in tests/Presentation.Tests.Integration/Controllers/MovieEndpointTests.cs. Add using ReviewEntities = Application.Reviews.Entities; next to the existing alias, then pick a movie that has reviews and ask for them.

    [Fact]
    public async Task GetMovieReviews_ShouldReturn_Ok()
    {
    // Arrange
    using var client = this.application.CreateClient();
    using var movieResponse = await client.GetAsync("/api/movies?limit=50");
    var movie = (await movieResponse.Content.ReadAsStringAsync())
    .Deserialize<Page<Entities.MovieSummary>>().Items
    .First(m => m.ReviewCount > 0);
    // Act
    using var response = await client.GetAsync($"/api/movies/{movie.Id}/reviews?limit=50");
    var result = (await response.Content.ReadAsStringAsync()).Deserialize<Page<ReviewEntities.Review>>();
    // Assert
    response.StatusCode.ShouldBe(HttpStatusCode.OK);
    result.TotalCount.ShouldBe(movie.ReviewCount);
    result.Items.ShouldAllBe(r => r.ReviewedMovie!.Id == movie.Id);
    }
  4. Run it.

    Terminal window
    dotnet test
    dotnet run --project ./src/Presentation
    curl "http://localhost:8080/api/movies/e159975e-4e6a-1ffd-713e-1eb9d8d4a593/reviews?limit=2"
    {
    "items": [
    { "id": "969cb90c-95ae-9189-1e52-0b38d34a911b", "stars": 4, "reviewedMovie": { "id": "e159975e-4e6a-1ffd-713e-1eb9d8d4a593", "title": "Assimilated disintermediate adapter" }, "reviewAuthor": { "id": "1cc2ad51-61c5-63ee-dd6e-15308cfe6e51", "firstName": "Caroline", "lastName": "Bins" } },
    { "id": "a5ebd747-0408-0a96-1af1-eb5cb93bc814", "stars": 1, "reviewedMovie": { "id": "e159975e-4e6a-1ffd-713e-1eb9d8d4a593", "title": "Assimilated disintermediate adapter" }, "reviewAuthor": { "id": "1cc2ad51-61c5-63ee-dd6e-15308cfe6e51", "firstName": "Caroline", "lastName": "Bins" } }
    ],
    "offset": 0,
    "limit": 2,
    "totalCount": 6
    }

    Caroline gave the same movie a 4 and a 1. Sample data has no continuity editor.

Open Scalar and the new endpoint is there under Movies, documented, with a working Send button. A limit of 99 comes back as a 400 Problem Details response and an unknown movie id comes back as an empty page, both before your code has to worry about it.