Using the ApiEndpoints One-Pager

one-pagers aspnetcore csharp

Introduction

A common problem with ApiControllers is that they tend to get bloated and out of control and do not tend to be cohesive.

A better approach is to write single-line action methods that route commands to handlers.

With ApiEndpoints, projects do not need any Controller classes.

With ApiEndpoints, you can simply create a folder per resouce instead of an ApiController per resource.

This is especially useful because real world applications are rarely just crud applications.

Most of the time you have commands and all you want to do is redirect them to handlers.

A quick example

Here is an example of how to use it.

Say you have a resource called Author and you would like to create an Author.

Normally, you would create an AuthorController with an HttpPost action.

With ApiEndpoints, you create an Authors folder and all actions related to Authors will be in a seperate command class

First, create the incoming request:-

public class CreateAuthorCommand
{
  [Required]
  public string Name { get; set; } = null!;
  [Required]
  public string PluralsightUrl { get; set; } = null!;
  public string? TwitterAlias { get; set; }
}

Second, the outgoing response

public class CreateAuthorResult : CreateAuthorCommand
{
  public int Id { get; set; }
}

Third, create the command itself

namespace SampleEndpointApp.Endpoints.Authors;

//The class inherits from EndpointBaseAsync, with the request and response configured.
public class Create : EndpointBaseAsync
    .WithRequest<CreateAuthorCommand>
    .WithActionResult
{
  private readonly IAsyncRepository<Author> _repository;
  private readonly IMapper _mapper;

  public Create(IAsyncRepository<Author> repository,
      IMapper mapper)
  {
    _repository = repository;
    _mapper = mapper;
  }

  /// <summary>
  /// Creates a new Author
  /// </summary>
  [HttpPost("api/[namespace]")]
  public override async Task<ActionResult> HandleAsync([FromBody] CreateAuthorCommand request, CancellationToken cancellationToken)
  {
    var author = new Author();
    _mapper.Map(request, author);
    await _repository.AddAsync(author, cancellationToken);

    var result = _mapper.Map<CreateAuthorResult>(author);
    return CreatedAtRoute("Authors_Get", new { id = result.Id }, result);
  }
}

The HttpPost will get called for the route api/authors.

The [namespace] in the route [HttpPost("api/[namespace]")] refers to authors in the namespace of the class.

Each api command has these 3 class files associated with it.

Other Examples

An example with a route parameter id

public class AuthorResult
{
  public string Id { get; set; } = null!;
  public string Name { get; set; } = null!;
  public string PluralsightUrl { get; set; } = null!;
  public string? TwitterAlias { get; set; }
}
public class Get : EndpointBaseAsync
      .WithRequest<int>
      .WithActionResult<AuthorResult>
{
  private readonly IAsyncRepository<Author> _repository;
  private readonly IMapper _mapper;

  public Get(IAsyncRepository<Author> repository,
      IMapper mapper)
  {
    _repository = repository;
    _mapper = mapper;
  }

  /// <summary>
  /// Get a specific Author
  /// </summary>
  [HttpGet("api/[namespace]/{id}", Name = "[namespace]_[controller]")]
  public override async Task<ActionResult<AuthorResult>> HandleAsync(int id, CancellationToken cancellationToken)
  {
    var author = await _repository.GetByIdAsync(id, cancellationToken);

    if (author is null) return NotFound();

    var result = _mapper.Map<AuthorResult>(author);

    return result;
  }
}

An example with FromRouteAttribute

public class DeleteAuthorRequest
{
  public int Id { get; set; }
}
public class Delete : EndpointBaseAsync
    .WithRequest<DeleteAuthorRequest>
    .WithActionResult
{
  private readonly IAsyncRepository<Author> _repository;

  public Delete(IAsyncRepository<Author> repository)
  {
    _repository = repository;
  }

  /// <summary>
  /// Deletes an Author
  /// </summary>
  [HttpDelete("api/[namespace]/{id}")]
  public override async Task<ActionResult> HandleAsync([FromRoute] DeleteAuthorRequest request, CancellationToken cancellationToken)
  {
    var author = await _repository.GetByIdAsync(request.Id, cancellationToken);

    if (author is null)
    {
      return NotFound(request.Id);
    }

    await _repository.DeleteAsync(author, cancellationToken);

    // see https://restfulapi.net/http-methods/#delete
    return NoContent();
  }
}

An example with FromBodyAttribute

public class UpdateAuthorCommand
{
  [Required]
  public int Id { get; set; }
  [Required]
  public string Name { get; set; } = null!;
}
public class UpdatedAuthorResult
{
  public string Id { get; set; } = null!;
  public string Name { get; set; } = null!;
  public string PluralsightUrl { get; set; } = null!;
  public string? TwitterAlias { get; set; }
}
public class Update : EndpointBaseAsync
    .WithRequest<UpdateAuthorCommand>
    .WithActionResult<UpdatedAuthorResult>
{
  private readonly IAsyncRepository<Author> _repository;
  private readonly IMapper _mapper;

  public Update(IAsyncRepository<Author> repository,
      IMapper mapper)
  {
    _repository = repository;
    _mapper = mapper;
  }

  /// <summary>
  /// Updates an existing Author
  /// </summary>
  [HttpPut("api/[namespace]")]
  public override async Task<ActionResult<UpdatedAuthorResult>> HandleAsync([FromBody] UpdateAuthorCommand request, CancellationToken cancellationToken)
  {
    var author = await _repository.GetByIdAsync(request.Id, cancellationToken);

    if (author is null) return NotFound();

    _mapper.Map(request, author);
    await _repository.UpdateAsync(author, cancellationToken);

    var result = _mapper.Map<UpdatedAuthorResult>(author);
    return result;
  }
}

An example with FromQueryAttribute

public class AuthorListRequest
{
  public int Page { get; set; }
  public int PerPage { get; set; }
}
public class AuthorListResult
{
  public int Id { get; set; }
  public string Name { get; set; } = null!;
  public string? TwitterAlias { get; set; }
}
public class List : EndpointBaseAsync
    .WithRequest<AuthorListRequest>
    .WithResult<IEnumerable<AuthorListResult>>
{
  private readonly IAsyncRepository<Author> repository;
  private readonly IMapper mapper;

  public List(
      IAsyncRepository<Author> repository,
      IMapper mapper)
  {
    this.repository = repository;
    this.mapper = mapper;
  }

  /// <summary>
  /// List all Authors
  /// </summary>
  [HttpGet("api/[namespace]")]
  public override async Task<IEnumerable<AuthorListResult>> HandleAsync(
      [FromQuery] AuthorListRequest request,
      CancellationToken cancellationToken = default)
  {
    if (request.PerPage == 0)
    {
      request.PerPage = 10;
    }
    if (request.Page == 0)
    {
      request.Page = 1;
    }
    var result = (await repository.ListAllAsync(request.PerPage, request.Page, cancellationToken))
        .Select(i => mapper.Map<AuthorListResult>(i));

    return result;
  }
}

An example with MultiAttribute

This reads the parameters from the route and the body

public class UpdateAuthorCommand
{
  [Required]
  public int Id { get; set; }
  [Required]
  public string Name { get; set; } = null!;
}
public class UpdatedAuthorResult
{
  public string Id { get; set; } = null!;
  public string Name { get; set; } = null!;
  public string PluralsightUrl { get; set; } = null!;
  public string? TwitterAlias { get; set; }
}
public class UpdateById : EndpointBaseAsync
    .WithRequest<UpdateAuthorCommandById>
    .WithActionResult<UpdatedAuthorByIdResult>
{
  private readonly IAsyncRepository<Author> _repository;
  private readonly IMapper _mapper;

  public UpdateById(IAsyncRepository<Author> repository,
      IMapper mapper)
  {
    _repository = repository;
    _mapper = mapper;
  }

  /// <summary>
  /// Updates an existing Author
  /// </summary>
  [HttpPut("api/[namespace]/{id}")]
  public override async Task<ActionResult<UpdatedAuthorByIdResult>> HandleAsync([FromMultiSource]UpdateAuthorCommandById request,
    CancellationToken cancellationToken)
  {
    var author = await _repository.GetByIdAsync(request.Id, cancellationToken);

    if (author is null) return NotFound();

    author.Name = request.Details.Name;
    author.TwitterAlias = request.Details.TwitterAlias;

    await _repository.UpdateAsync(author, cancellationToken);

    var result = _mapper.Map<UpdatedAuthorByIdResult>(author);
    return result;
  }
}

An example that returns a file stream and does not accept any request

public class ListJsonFile : EndpointBaseAsync
    .WithoutRequest
    .WithActionResult
{
  private readonly IAsyncRepository<Author> repository;

  public ListJsonFile(IAsyncRepository<Author> repository)
  {
    this.repository = repository;
  }

  /// <summary>
  /// List all Authors as a JSON file
  /// </summary>
  [HttpGet("api/[namespace]/Json")]
  public override async Task<ActionResult> HandleAsync(
      CancellationToken cancellationToken = default)
  {
    var result = (await repository.ListAllAsync(cancellationToken)).ToList();

    var streamData = JsonSerializer.SerializeToUtf8Bytes(result);
    return File(streamData, "text/json", "authors.json");
  }
}

An example that returns an IAsyncEnumerable

public class AuthorListResult
{
  public int Id { get; set; }
  public string Name { get; set; } = null!;
  public string? TwitterAlias { get; set; }
}
public class Stream : EndpointBaseAsync
    .WithoutRequest
    .WithAsyncEnumerableResult<AuthorListResult>
{
  private readonly IAsyncRepository<Author> repository;
  private readonly IMapper mapper;

  public Stream(
      IAsyncRepository<Author> repository,
      IMapper mapper)
  {
    this.repository = repository;
    this.mapper = mapper;
  }

  /// <summary>
  /// Stream all authors with a one second delay between entries
  /// </summary>
  [HttpGet("api/[namespace]/stream")]
  public override async IAsyncEnumerable<AuthorListResult> HandleAsync([EnumeratorCancellation] CancellationToken cancellationToken)
  {
    var result = await repository.ListAllAsync(cancellationToken);
    foreach (var author in result)
    {
      yield return mapper.Map<AuthorListResult>(author);
      await Task.Delay(1000, cancellationToken);
    }
  }
}

Setup

It is important to add the following lines to Startup to correctly setup its use.

First, ConfigureServices


// This is what helps with using [namespace] in the route token
services.AddControllers(options =>
{
  options.UseNamespaceRouteToken();
 });

// This allows the use of action parameters for model binding.
// https://stackoverflow.com/questions/51423340/binding-source-parameter-inference-in-asp-net-core
services.Configure<ApiBehaviorOptions>(options =>
{
  options.SuppressInferBindingSourcesForParameters = true;
});

// Adds the endpoints to the swagger definition
services.AddSwaggerGen(c =>
{
  c.SwaggerDoc("v1", new OpenApiInfo { Title = "SampleEndpointApp", Version = "v1" });
  c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, "SampleEndpointApp.xml"));
  c.UseApiEndpoints();
});

Second, no middleware to be configured specially to use this library.


if (env.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
}

app.UseHttpsRedirection();

app.UseRouting();

app.UseAuthorization();

// Enable middleware to serve generated Swagger as a JSON endpoint.
app.UseSwagger();

// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "SampleEndpointApp V1"));

app.UseEndpoints(endpoints =>
{
  endpoints.MapControllers();
});

Functional Tests

Every endpoint can be functionally tested.

You need to import the namespace Ardalis.HttpClientTestExtensions.

Following is a sample of a CreateEndpoint Test:-

public class CreateEndpoint : IClassFixture<CustomWebApplicationFactory<Startup>>
{
  private readonly HttpClient _client;

  public CreateEndpoint(CustomWebApplicationFactory<Startup> factory)
  {
    _client = factory.CreateClient();
  }

  [Fact]
  public async Task CreatesANewAuthor()
  {
    var newAuthor = new CreateAuthorCommand()
    {
      Name = "James Eastham",
      PluralsightUrl = "https://app.pluralsight.com",
      TwitterAlias = "jeasthamdev",
    };

    var lastAuthor = SeedData.Authors().Last();

    var response = await _client.PostAsync(Routes.Authors.Create, new StringContent(JsonConvert.SerializeObject(newAuthor), Encoding.UTF8, "application/json"));

    response.EnsureSuccessStatusCode();
    var stringResponse = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<CreateAuthorResult>(stringResponse);

    Assert.NotNull(result);
    Assert.Equal(result.Id, lastAuthor.Id + 1);
    Assert.Equal(result.Name, newAuthor.Name);
    Assert.Equal(result.PluralsightUrl, newAuthor.PluralsightUrl);
    Assert.Equal(result.TwitterAlias, newAuthor.TwitterAlias);
  }

  [Fact]
  public async Task GivenLongRunningCreateRequest_WhenTokenSourceCallsForCancellation_RequestIsTerminated()
  {
    // Arrange, generate a token source that times out instantly
    var tokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(0));
    var lastAuthor = SeedData.Authors().Last();
    var newAuthor = new CreateAuthorCommand()
    {
      Name = "James Eastham",
      PluralsightUrl = "https://app.pluralsight.com",
      TwitterAlias = "jeasthamdev",
    };

    // Act
    var request = _client.PostAsync(Routes.Authors.Create, new StringContent(JsonConvert.SerializeObject(newAuthor), Encoding.UTF8, "application/json"), tokenSource.Token);

    // Assert
    await Assert.ThrowsAsync<OperationCanceledException>(async () => await request);
  }
}

References