Wissance.WebApiToolkit

GitHub code size in bytes GitHub issues GitHub Release Date GitHub release (latest by date)

This lib helps to build REST API with C# and AspNet easily than writing it from scratch over and over in different projects.

WebApiToolkit helps to build application easily

1. Key Features

  • REST API Controller with full CRUD contains only 20 lines of code (~ 10 are imports)
    • GET methods have built-in paging support;
    • GET methods have built-in sorting and filter by query params (coming soon, MORE STARS -> sooner RELEASE);
  • support to work with any persistent storage (IModelManager interface); Good built-in EntityFramework support (see EfModelManager class). See WeatherControl App which has 2 WEB API projects:
    • Wissance.WeatherControl.WebApi uses EntityFramework;
    • Wissance.WeatherControl.WebApi.V2 uses EdgeDb.

Key concepts:

  1. Controller is a class that handles HTTP-requests to REST Resource.
  2. REST Resource is equal to Entity class / Database Table
  3. Every operation on REST Resource produce JSON with DTO as output. We ASSUME to use only one DTO class with all REST methods.

2. API Contract

  • DTO classes:

    • OperationResultDto represents result of operation that changes Data in db;
    • PagedDataDto represents portion (page) of same objects (any type);
  • Controllers classes - abstract classes

    • basic read controller (BasicReadController) contains 2 methods:
      • GET /api/[controller]/?page={page}&size={size} to get PagedDataDto
      • GET /api/[controller]/{id} to get one object by id
    • full CRUD controller (BasicCrudController) = basic read controller (BasicReadController) + Create, Update and Delete operations :
      • POST /api/[controller] - for new object creation
      • PUT /api/[controller]/{id} - for edit object by id
      • DELETE /api/[controller]/{id} - for delete object by id

    Controllers classes expects that all operation will be performed using Manager classes (each controller must have it own manager)

  • Managers classes - classes that implements business logic of application

    • IModelManager - interface that describes basic operations
    • EfModelManager- is abstract class that contains implementation of Get and Delete operations

3. Requirements

There is only ONE requirement: all Entity classes for any Persistence storage that are using with controllers & managers MUST implements IModelIdentifiable<T> from Wissance.WebApiToolkit.Data.Entity. If this toolkit should be used with EntityFramework you should derive you resource manager from EfModelManager it have built-in methods for:

  • get many
  • get one
  • delete

4. Toolkit usage algorithm with EntityFramework

Full example is mentioned in section 6 (see below). But if you are starting to build new REST Resource API you should do following:

  1. Create a model (entity) class implementing IModelIdentifiable<T> and DTO class for it representation, i.e.:
public class BookEntity : IModelIdentifiable<int>
{
    public int Id {get; set;}
    public string Title {get; set;}
    public string Authors {get; set;} // for simplicity
    public DateTimeOffset Created {get; set;}
    public DateTimeOffset Updated {get; set;}
}

public class BookDto
{
    public int Id {get; set;}
    public string Title {get; set;}
    public string Authors {get; set;} 
}
  1. Create a factory function (i.e. static function of a static class) that converts Model to DTO i.e.:
public static class BookFactory
{
    public static BookDto Create(BookEntity entity)
    {
        return new BookDto
        {
            Id = entity.Id,
            Title = entity.Title,
            Authors = entity.Authors;
        };
    }
}
  1. Create IModelContext interface that has you BookEntity as a DbSet and it's implementation class that also derives from DbContext (Ef abstract class):
public interface IModelContext
{
    DbSet<BookEntity> Books {get;set;}
}

public MoidelContext: DbContext<ModelContext>, IModelContext
{
    // todo: not mrntioned here constructor, entity mapping and so on
    public DbSet<BookEntity> Books {get; set;}
}
  1. Configure to inject ModelContext as a DbContext via DI see Startup class
  2. Create Controller class and a manager class pair, i.e. consider here full CRUD
[ApiController]
public class BookController : BasicCrudController<BookDto, BookEntity, int>
{
    public BookController(BookManager manager)
    {
        Manager = manager;  // this is for basic operations
        _manager = manager; // this for extended operations
    }
    
    private BookManager _manager;
}

public class BookManager : EfModelManager<BookEntity, BookDto, int>
{
    public BookManager(ModelContext modelContext, ILoggerFactory loggerFactory) : base(modelContext, BookFactory.Create, loggerFactory)
    {
        _modelContext = modelContext;
    }
    
    public override async Task<OperationResultDto<StationDto>> CreateAsync(StationDto data)
    {
        // todo: implement
    }
    
    public override async Task<OperationResultDto<StationDto>> UpdateAsync(int id, StationDto data)
    {
        // todo: implement
    }
    
    private readonly ModelContext _modelContext;
}

5. Nuget package

You could find nuget-package here

6. Examples

Here we consider only Full CRUD controllers because Full CRUD = Read Only + Additional Operations (CREATE, UPDATE, DELETE), a full example = full application created with Wissance.WebApiToolkit could be found here: https://github.com/Wissance/WeatherControl

[ApiController]
public class StationController : BasicCrudController<StationDto, StationEntity, int>
{
    public StationController(StationManager manager)
    {
        Manager = manager;  // this is for basic operations
        _manager = manager; // this for extended operations
    }
    
    private StationManager _manager;
}
public class StationManager : EfModelManager<StationEntity, StationDto, int>
{
    public StationManager(ModelContext modelContext, ILoggerFactory loggerFactory) : base(modelContext, StationFactory.Create, loggerFactory)
    {
        _modelContext = modelContext;
    }

    public override async Task<OperationResultDto<StationDto>> CreateAsync(StationDto data)
    {
        try
        {
            StationEntity entity = StationFactory.Create(data);
            await _modelContext.Stations.AddAsync(entity);
            int result = await _modelContext.SaveChangesAsync();
            if (result >= 0)
            {
                return new OperationResultDto<StationDto>(true, (int)HttpStatusCode.Created, null, StationFactory.Create(entity));
            }
            return new OperationResultDto<StationDto>(false, (int)HttpStatusCode.InternalServerError, "An unknown error occurred during station creation", null);

            }
        catch (Exception e)
        {
            return new OperationResultDto<StationDto>(false, (int)HttpStatusCode.InternalServerError, $"An error occurred during station creation: {e.Message}", null);
        }
    }

    public override async Task<OperationResultDto<StationDto>> UpdateAsync(int id, StationDto data)
    {
        try
        {
            StationEntity entity = StationFactory.Create(data);
            StationEntity existingEntity = await _modelContext.Stations.FirstOrDefaultAsync(s => s.Id == id);
            if (existingEntity == null)
            {
                return new OperationResultDto<StationDto>(false, (int)HttpStatusCode.NotFound, $"Station with id: {id} does not exists", null);
            }

            // Copy only name, description and positions, create measurements if necessary from MeasurementsManager
            existingEntity.Name = entity.Name;
            existingEntity.Description = existingEntity.Description;
            existingEntity.Latitude = existingEntity.Latitude;
            existingEntity.Longitude = existingEntity.Longitude;
            int result = await _modelContext.SaveChangesAsync();
            if (result >= 0)
            {
                return new OperationResultDto<StationDto>(true, (int)HttpStatusCode.OK, null, StationFactory.Create(entity));
            }
            return new OperationResultDto<StationDto>(false, (int)HttpStatusCode.InternalServerError, "An unknown error occurred during station update", null);

        }
        catch (Exception e)
        {
             return new OperationResultDto<StationDto>(false, (int)HttpStatusCode.InternalServerError, $"An error occurred during station update: {e.Message}", null);
        }
            
    }

    private readonly ModelContext _modelContext;
}

JUST 2 VERY SIMPLE CLASSES ^^ USING WebApiToolkit

7. Extending API

7.1 Add new methods to existing controller

Consider we would like to add method search to our controller:

[HttpGet]
[Route("api/[controller]/search")]
public async Task<PagedDataDto<BookDto>>> SearchAsync([FromQuery]string query, [FromQuery]int page, [FromQuery]int size)
{ 
    OperationResultDto<Tuple<IList<BookDto>, long>> result = await Manager.GetAsync(page, size, query);
    if (result == null)
    {
        HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
    }

    HttpContext.Response.StatusCode = result.Status;
    return new PagedDataDto<TRes>(pageNumber, result.Data.Item2, GetTotalPages(result.Data.Item2, pageSize), result.Data.Item1);
}

7.2 Add security to protect you API

We have additional project to protect API with Keycloak OpenId-Connect. pass IHttpContextAccessor to Manager class and check something like this: ClaimsPrincipal principal = _httpContext.HttpContext.User;

8. Additional materials

You could see our articles about Toolkit usage:

9. Contributors