Skip to content

Instantly share code, notes, and snippets.

@jsheridanwells
Last active April 30, 2018 19:23
Show Gist options
  • Save jsheridanwells/0fea5aaa930fb3e820e733d4711762cf to your computer and use it in GitHub Desktop.
Save jsheridanwells/0fea5aaa930fb3e820e733d4711762cf to your computer and use it in GitHub Desktop.
.NET API Setup

ASP.NET API Setup Part 1

Starting

  1. Creating Project: Scaffolding:
  • New Project -> .NET Core -> ASP.NET Core Web Application
  • Then, API template
  1. Use NuGet to check for any package updates

Configuration

In launchSettings.json, set launchBrowser to false

In Startup.cs: In ConfigureServices, turn off Pascal-case routing names"

services.AddRouting(opt => opt.LowercaseUrls = true);

Root Controller

Delete default controller and add RootController.cs

  • Allow RootController to inherit from : Controller namespace. (and import)
  • Set up first route. Routes return IActionResult which can return HTTP status codes, json payloads, or both.
  • For now, make the Root return a reference to itself
  • Url.Link method returns Urls for controller methods
[Route("/")]
  public class RootController : Controller
  {
      [HttpGet(Name = nameof(GetRoot))]
      public IActionResult GetRoot()
      {
          var response = new { href = Url.Link(nameof(GetRoot), null) };
          return Ok(response);
      }
  }

Versioning

Media Type Versioning:

  1. Install Microsoft.AspNetCoreMvc.Versioning from NuGet
  2. In Startup#ConfigureServices:
services.AddApiVersioning(opt =>
           {
               opt.ApiVersionReader = new MediaTypeApiVersionReader();
               opt.AssumeDefaultVersionWhenUnspecified = true;
               opt.ReportApiVersions = true;
               opt.DefaultApiVersion = new ApiVersion(1, 0);
               opt.ApiVersionSelector = new CurrentImplementationApiVersionSelector(opt);
           });

In RouteController.cs, add Api Version attribute to route:

[Route("/")]
   [ApiVersion("1.0")]
   public class RootController : Controller // ... //

Now, specific controllers or methods can be versioned, otherwise everything will be v1.0

Routing With Templates

Add new route. [Route("[controller]")] automatically includes name of current controller

[Route("/[controller]")]  // controller template will automatically match name of controller ("/Rooms")
   public class RoomsController : Controller
   {
       [HttpGet(Name = nameof(GetRooms))]
       public IActionResult GetRooms()
       {
           throw new NotImplementedException();
       }
   }

Add route to Routes Controller as new object:

 public IActionResult GetRoot()
       {
           var response = new {
               href = Url.Link(nameof(GetRoot), null),
               rooms = new { href = Url.Link(nameof(RoomsController.GetRooms), null) }
           };
           return Ok(response);
       }

Serializing Errors

Create models and ApiError model: Models\ApiError.cs

Add this model:

 public class ApiError
   {
       public string Message { get; set; }
       public string Detail { get; set; }
       [JsonProperty(NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore)]
       [DefaultValue("")]
       public string StackTrace { get; set; }
   }

Create filters for filter interfaces: Filters\JsonExceptionFilter.cs Add this filter:

  public class JsonExceptionFilter : IExceptionFilter
   {
       private readonly IHostingEnvironment _env;
       public JsonExceptionFilter(IHostingEnvironment env)
       {
           _env = env;
       }
       public void OnException(ExceptionContext context)
       {
           var error = new ApiError();

           if (_env.IsDevelopment())
           {
               error.Message = context.Exception.Message;
               error.Detail = context.Exception.StackTrace;
           }
           else
           {
               error.Message = "An error has occured...";
               error.Detail = context.Exception.Message;
           }
           
           
           context.Result = new ObjectResult(error)
           {
               StatusCode = 500
           };
       }

   }

Add filter at runtime. In Startup.cs #ConfigureServices, services.AddMvc(opt => {/.../}) :

opt.Filters.Add(typeof(JsonExceptionFilter));

Transport Security

  1. Enable HTTPS (SSL Support)

Click Solution -> Properties -> Debug Check Enable SSL

Enforce HTTPS: (You could add [RequireHttps] to every controller) To add to the entire API: In Startup.cs => #ConfigureServices => Services.AddMvc(opt => /.../)

opt.Filters.Add(typeof(RequireHttpsAttribute));

For development, add way to capture randomly-generated SSL port:

At top of Startup class:

private readonly int? _httpsPort;

Modify constructor:

public Startup(IConfiguration configuration, IHostingEnvironment env)
       {
           Configuration = configuration;
           if (env.IsDevelopment())
           {
               var launchJsonConfig = new ConfigurationBuilder()
                   .SetBasePath(env.ContentRootPath)
                   .AddJsonFile("Properties\\launchSettings.json")
                   .Build();
               _httpsPort = launchJsonConfig.GetValue<int>("iisSettings:iisExpress:sslPort");
           }
       }

Add SSL port to AddMvc options:

                opt.SslPort = _httpsPort;
  1. Addt'l security headers

In NuGet, install: NWebSec.AspNetCore.Middleware.

In Startup.cs#Configure:

           app.UseHsts(opt => {
               opt.MaxAge(days: 180);
               opt.IncludeSubdomains();
               opt.Preload();
           });

This adds Strict-Transport-Security layer for browsers that respect this.

Model Resource Class

In Models, create Resource.cs.

 public abstract class Resource
   {
       [JsonProperty(Order = -2)]
       public string Href { get; set; }
   }

New Model:

In Models, create new file(HotelInfo.cs) and it should inherit rfom Resource:

public class HotelInfo : Resource /.../

Add Attributes:

public class HotelInfo : Resource
   {
       public string Title { get; set;  }
       public string Tagline { get; set; }
       public string Emaeil{ get; set; }
       public string Website{ get; set; }
       public Address Location { get; set; }
       
   }
   public class Address
   {
       public string Street{ get; set; }
       public string City { get; set; }
       public string Country{ get; set; }
   }

Populating API w/ data using Configuration Pattern

Add data to appSettings.json.

{
 "Logging": {
   "IncludeScopes": false,
   "Debug": {
     "LogLevel": {
       "Default": "Warning"
     }
   },
   "Console": {
     "LogLevel": {
       "Default": "Warning"
     }
   },
   "Info": {
     "title": "The Landon Hotel West End London",
     "tagline": "You'll feel at home in our neighborhood",
     "location": {
       "street": "123 Oxford Street",
       "city": "London W1S 2YF",
       "country": "England"
     },
     "email": "[email protected]",
     "website": "www.landonhotel.com"
   }
 }
}

Bring Info: node into Startup#ConfigureServices:

services.Configure<HotelInfo>(Configuration.GetSection("Info"));

Create constructor which imports hotel data via IOptions:

       private readonly HotelInfo _hotelInfo;
       public InfoController(IOptions<HotelInfo> hotelInfoAccessor)
       {
           _hotelInfo = hotelInfoAccessor.Value;
       }

Create Route:

 [HttpGet(Name = nameof(GetInfo))]
       public IActionResult GetInfo()
       {
           _hotelInfo.Href = Url.Link(nameof(GetInfo), null);
           return Ok(_hotelInfo);
       }

Add route to RootController response:

hotelInfo = new { href = Url.Link(nameof(InfoController.GetInfo), null) }

Setting up In-Memory Database

  1. Install Microsoft.EntityFrameworkCore and Microsoft.EntityFrameworkCore.InMemory (NuGet)

  2. Add a DBContext class (this one's at root of the project). It will inherit from DbContext class

public class HotelApiContext : DbContext

Create constructor: (RoomEntity class has not been created yet:

        public HotelApiContext(DbContextOptions options)
            : base(options) { }

        public DbSet<RoomEntity> Rooms { get; set; }

In Startup.cs, add EFC using: using Microsoft.EntityFrameworkCore;.

InStartup::ConfigureServices:

            services.AddDbContext<HotelApiContext>(opt => opt.UseInMemoryDatabase());

Creating Data Model Class:

To models add: RoomEntity.cs Create Entity

    public class RoomEntity
    {
        public Guid Id { get; set; }
        public string Name{ get; set; }
        public int Rate { get; set; }

    }

Create Model: Rooms.cs which inherits Resource. Note: Creating Entity and Model separately helps control what data gets returned in the API.

Seeding InMemory Database

In Startup::ConfigureServices, in the env.IsDevelopment() block:

               using (var serviceScope = app.ApplicationServices.CreateScope())
               {
                   var context = serviceScope.ServiceProvider.GetService<HotelApiContext>();
                   AddTestData(context);
               }

Notes: Scoping error workaround came from here: https://stackoverflow.com/questions/46063945/cannot-resolve-dbcontext-in-asp-net-core-2-0/46064116.

Create private AddTestData method:

private static void AddTestData(HotelApiContext context)
       {
           context.Rooms.Add(new RoomEntity
           {
               Id = Guid.Parse("301df04d-8679-4b1b-ab92-0a586ae53d08"),
               Name = "Oxford Suite",
               Rate = 10119,
           });

           context.Rooms.Add(new RoomEntity
           {
               Id = Guid.Parse("ee2b83be-91db-4de5-8122-35a9e9195976"),
               Name = "Driscoll Suite",
               Rate = 23959
           });

           context.SaveChanges();
       }

Return a Resource From a Controller

In RoomsController, create constructor to inject Rooms DbContext:

       private readonly HotelApiContext _context;
       protected RoomsController(HotelApiContext context)
       {
           _context = context;
       }

For returning single item, create async function using Task:

 // /rooms/{roomId}
       [HttpGet("{roomId}", Name = nameof(GetRoomByIdAsync))]
       public async Task<IActionResult> GetRoomByIdAsync(Guid roomId, CancellationToken ct)
       {
           var entity = await _context.Rooms.SingleOrDefaultAsync(r => r.Id == roomId, ct);
           if (entity == null) return NotFound();

           var resource = new Room
           {
               Href = Url.Link(nameof(GetRoomByIdAsync), new { roomId = entity.Id }),
               Name = entity.Name,
               Rate = entity.Rate / 100.0m
           };

           return Ok(resource);
       }

Refactor the Data Context using a service

This is done so that controller is not interacting with Data Context directly, whcih separates concerns and makes controller easier to test with mocks.

Create services folder, create interface: Services\IRoomService.cs

Create this interface:

   public interface IRoomService
   {
       Task<Room> GetRoomAsync(Guid id, CancellationToken ct);
   }

Create class to implement IRoomService: Services/DefaultRoomService.cs (Default name makes it distinguishable from a mock service) DefaultService will implement the interface. ApiContext will be injected. Copy data access blocks from controller. NotFound() gets changed to return null since it's part of controller class.

       public async Task<Room> GetRoomAsync(Guid id, CancellationToken ct)
       {
           // the call returns the entity
           var entity = await _context.Rooms.SingleOrDefaultAsync(r => r.Id == id, ct); 
           if (entity == null) return null;

           // the entity properties are extracted into Room resource which is returned to the user
           var resource = new Room
           {
               Href = null, // Url.Link(nameof(GetRoomByIdAsync), new { roomId = entity.Id }),
               Name = entity.Name,
               Rate = entity.Rate / 100.0m
           };

           return resource;
       }

Back to controller, change constructor to point to IRoomsService:

       private readonly IRoomService _roomContext;

       public RoomsController(IRoomService context)
       {
           _roomContext = context;
       }

Change task to implement service:

       // /rooms/{roomId}
       [HttpGet("{roomId}", Name = nameof(GetRoomByIdAsync))]
       public async Task<IActionResult> GetRoomByIdAsync(Guid roomId, CancellationToken ct)
       {

           var resource = await _roomContext.GetRoomAsync(roomId, ct);
           if (resource == null) return NotFound();

           return Ok(resource);
       }

Finally, modify configuration services in Startup so that a DefaultRoomService is called every tim IRoomService is called (no singletons). In Startup:ConfigureServices:

services.AddScoped<IRoomService, DefaultRoomService>();

Mapping Models Automatically

In NuGet, install AutoMapper

Create mapping profile class in Infrastructure: Infrastructure/MappingProfile.cs Build out this class:

   public class MappingProfile : Profile
   {
       protected MappingProfile()
       {
           CreateMap<RoomEntity, Room>()
               .ForMember(dest => dest.Rate, opt => opt.MapFrom(src => src.Rate / 100.0m));
       }
   }

From NuGet install AutoMapper.Extensions.Microsoft.DependencyInjection

Add AutoMapper in `Startup:ConfigureServices

services.AddAutoMapper();  // also, import AutoMapper using AutoMapper;

Refactor DefaultRoomService as:

public class DefaultRoomService : IRoomService
   {
       private readonly HotelApiContext _context;
       public DefaultRoomService(HotelApiContext hotelApiContext)
       {
           _context = hotelApiContext;
       }
       public async Task<Room> GetRoomAsync(Guid id, CancellationToken ct)
       {
           // the call returns the entity
           var entity = await _context.Rooms.SingleOrDefaultAsync(r => r.Id == id, ct);
           if (entity == null) return null;

           return Mapper.Map<Room>(entity);
       }
   }

Refactoring Link Generation

ION specifications have three definitions: href, method, and rel (relation):

{
 "href" : "https://example.com",
 "method" : "GET",
 "rel" : [ "collection" ]
}

Create a Link Model

       public class Link
       {

       public const string GET = "GET";
       public static Link To(string routeName, object routeValues = null)
           => new Link
           {
               RouteName = routeName,
               RouteValues = routeValues,
               Method = GET,
               Relations = null
           };

       [JsonProperty(Order = -4)]  // puts this at top of body
       public string Href { get; set; }
       [JsonProperty(Order = -3, NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore)]
       [DefaultValue(GET)]  // sets GET const as default value
       public string Method { get; set; }
       [JsonProperty(Order = -2, PropertyName = "rel", NullValueHandling = NullValueHandling.Ignore)]  // PropertyName returns "rel" instead of "Relations"
       public string[] Relations { get; set; }

       [JsonIgnore]  // these will store route name and route values, JsonIgnore excludes them from Json response body
       public string RouteName { get; set; }
       [JsonIgnore]
       public object RouteValues { get; set; }
   }

Refactor RootController:GetRoot to return RootResponse:

       [HttpGet(Name = nameof(GetRoot))]
       public IActionResult GetRoot()
       {
           var response = new RootResponse {
               Href = null,
               rooms = Link.To(nameof(RoomsController.GetRooms)),
               Info = Link.To(nameof(InfoController.GetInfo))
           };

           return Ok(response);
       }

Create RootResponse model:

   public class RootResponse : Resource
   {
       public Link Info { get; set; }
       public Link rooms { get; set; }
   }

Rewrite Links with a Filter:

Create Infrastructure\LinkReWriter.cs Add:

   public class LinkRewriter
   {
       private readonly IUrlHelper _urlHelper;
       public LinkRewriter(IUrlHelper urlHelper)  // Bring in IUrlHelper from MVC
       {
           _urlHelper = urlHelper;
       }

       public Link Rewrite(Link original)
       {
           if (original == null) return null; // Sanity check for null values

           return new Link
           {
               Href = _urlHelper.Link(original.RouteName, original.RouteValues),  // Link Href is created here
               Method = original.Method,  // everything else is just passed on
               Relations = original.Relations
           };
       }
   }

Create new filter: Filters\LinkRewritingFilter.cs

(I have no idea what this does :( )

public class LinkRewritingFilter : IAsyncResultFilter

   {
       private readonly IUrlHelperFactory _urlHelperFactory;
       public LinkRewritingFilter(IUrlHelperFactory urlHelper)
       {
           _urlHelperFactory = urlHelper;
       }
       public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
       {
           var asObjectResult = context.Result as ObjectResult;
           bool shouldSkip = asObjectResult?.Value == null || asObjectResult?.StatusCode != (int)HttpStatusCode.OK;
           if (shouldSkip)
           {
               await next();
               return;
           }

           var rewriter = new LinkRewriter(_urlHelperFactory.GetUrlHelper(context));
           RewriteAllLinks(asObjectResult.Value, rewriter);


       }

       private static void RewriteAllLinks(object model, LinkRewriter rewriter)
       {
           if (model == null) return;

           var allProperties = model.GetType().GetTypeInfo().GetAllProperties().Where(prop => prop.CanRead).ToArray();

           var linkProperties = allProperties
               .Where(prop => prop.CanWrite && prop.PropertyType == typeof(Link));

           foreach (var linkProperty in linkProperties)
           {
               var rewritten = rewriter.Rewrite(linkProperty.GetValue(model) as Link);
               if (rewritten == null) continue;

               linkProperty.SetValue(model, rewritten);
           }

           var arrayProperties = allProperties.Where(prop => prop.PropertyType.IsArray);
           RewriteLinksInArrays(arrayProperties, model, rewriter);

           var objectProperties = allProperties.Except(linkProperties).Except(arrayProperties);
           RewriteLinksInNestedObjects(objectProperties, model, rewriter);

       }

       private static void RewriteLinksInNestedObjects(
           IEnumerable<PropertyInfo> objectProperties,
           object obj,
           LinkRewriter rewriter
           )
       {
           foreach (var objectProperty in objectProperties)
           {
               if (objectProperty.PropertyType == typeof(string))
               {
                   continue;
               }

               var typeInfo = objectProperty.PropertyType.GetTypeInfo();
               if (typeInfo.IsClass)
               {
                   RewriteAllLinks(objectProperty.GetValue(obj), rewriter);
               }
           }
       }

       private static void RewriteLinksInArrays(
           IEnumerable<PropertyInfo> arrayProperties,
           object obj,
           LinkRewriter rewriter
           )
       {
           foreach (var arrayProperty in arrayProperties)
           {
               var array = arrayProperty.GetValue(obj) as Array ?? new Array[0];

               foreach (var element in array)
               {
                   RewriteAllLinks(element, rewriter);
               }
           }
       }
   }

Add filter in Startup:ConfigurationServices:

services.AddMcv(opt => opt.Filters.Add(typeof(LinkRewritingFilter)));

Extending Filter for Resource URL as well:

In Resource.cs model, change href string to public Link Self { get; set; }

In RootController, change 'Href' property in response to: Self = Link.To(nameof(GetRoot)).

In MappingProfile, add the following to MappingProfile():

.ForMember(dest => dest.Self, opt => opt.MapFrom(src => 
                   Link.To(nameof(Controllers.RoomsController.GetRoomByIdAsync), new { roomId = src.Id })));

Update Resource.cs model to inherit Link: public abstract class Resource : Link. then, Add [JsonIgnore] attribute over Self property

in LinkRewritingfilters:RewriteAllLinks, add special check to handle hidden Self property (at the end of the `foreach loop):

               if (linkProperty.Name == nameof(Resource.Self))
               {
                   allProperties.SingleOrDefault(p => p.Name == nameof(Resource.Href))
                       ?.SetValue(model, rewritten.Href);
                   allProperties.SingleOrDefault(p => p.Name == nameof(Resource.Method))
                       ?.SetValue(model, rewritten.Method);
                   allProperties.SingleOrDefault(p => p.Name == nameof(Resource.Relations))
                       ?.SetValue(model, rewritten.Relations);
               }

ASP.NET API Setup Part 2

Creating Collections

Create new model called Collection(T).cs Class will be generic and will inherit from Resource class:

public class Collection<T> : Resource

Class will contain generic array called Value: public T[] Value { get; set; }

In RoomsController.cs:

Update :GetRooms to:

        public async Task<IActionResult>GetRoomsAsync(CancellationToken ct)
        {
            var rooms = await _roomContext.GetRoomsAsync(ct);

            var collectionLink = Link.To(nameof(GetRoomsAsync));
            var collection = new Collection<Room>
            {
                Self = collectionLink,
                Value = rooms.ToArray()
            };

            return Ok(collection);
        }

Update IRoomService:

        Task<IEnumerable<Room>> GetRoomsAsync(CancellationToken ct);

Add GetRoomsAsync to DefaultRoomServices:

        public async Task<IEnumerable<Room>> GetRoomsAsync(CancellationToken ct)
        {
            var query = _context.Rooms.ProjectTo<Room>();  // .ProjectTo() comes from AutoMapper
            return await query.ToArrayAsync();
        }

Add LinkToCollection method in Link mpdel:

            public static Link ToCollection(string routeName, object routeValues = null)
            => new Link
            {
                RouteName = routeName,
                RouteValues = routeValues,
                Method = GET,
                Relations = new string[] { "collection" }
             };

Then update RoomsController:GetRoomsAsync -> var collectionLink = Link.ToCollection(nameof(GetRoomsAsync));

Pagination

Create new model: PagedCollection{T}.cs Add the following properties:

  • Offset - start page
  • Limit - number of items per page
  • Total Size - Total number of items
  • Next, Previous, First, and Last. Also, add [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] to omit fields from Json response if they are empty:
 public class PagedCollection<T> : Collection<T>
    {
        [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
        public int? Offset { get; set; } // start page
        [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]

        public int? Limit { get; set; }  // how many items to return
        public int Size { get; set; } // total numbner of items in collection

        [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]

        public Link First { get; set; }
        [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
        public Link Previous { get; set; }
        [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
        public Link Next { get; set; }
        [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
        public Link Last { get; set; }

    }

In RoomsController:GetAllRoomOpeningsAsyncc, add paging options to parameters (FromQuery attribute specifies that PaginOptions come from URL paramters:

public async Task<IActionResult> GetAllRoomOpeningsAsync([FromQuery] PagingOptions pagingOptions, CancellationToken ct) { /.../ }

Create PagingOptions model to hold OffSet, Limit values: PagingOptions.cs, [Range] attribute limits size, comes from ComponentyModel.DataAnnotations namespace:

    public class PagingOptions
    {
        [Range(1, 9999, ErrorMessage = "Offset must be greater than zero....")]
        public int? Offset { get; set; }
        [Range(1, 100, ErrorMessage = "Offset must be greater than zero, and less than 100....")]
        public int? Limit { get; set; }

    }

Creating a model with these parameters allows you to annotate them.

Back in RoomsController:GetAllOpeningsAsync: Pass parameters to method and +opening.Service call

public async Task<IActionResult> GetAllRoomOpeningsAsync([FromQuery] PagingOptions pagingOptions, CancellationToken ct) /.../
        
var openings = await _openingService.GetOpeningsAsync(pagingOptions, ct); /.../

Add params to IOpeningService.cs:

        Task<PagedResults<Opening>> GetOpeningsAsync(PagingOptions paginingOptions, CancellationToken ct);

Add params to DefaultOpeningService:

        public async Task<PagedResults<Opening>> GetOpeningsAsync(PagingOptions pagingOptions, CancellationToken ct)

Create new variable to

                var pagedOpenings = allOpenings
                .Skip(pagingOptions.Offset.Value)
                .Take(pagingOptions.Limit.Value);

(.Skip and .Take methods come from System.Collections.Generic namespace) Create a new model for PagedResults.cs:

    public class PagedResults<T>
    {
        public IEnumerable<T> Items { get; set; }
        public int TotalSize { get; set; }
    }

This allowd us to organize returned items and total size of collection.

Back in DefaulyOpeningsService.cs, modify return statement to include PagedResults:

            return new PagedResults<Opening>
            {
                Items = pagedOpenings,
                TotalSize = allOpenings.Count
            };

Add defaults so that there is no error for requests w/out Offset and Query params.

In appsettings.json, add DefaultPagingOptions object:

"DefaultPagingOptions": {
    "Limit": 25,
    "offset":  0
  }

(Default Limit and Offset vlaues can be whatever makes sense for your API).

In Startup.cs load values from configuration":

services.Configure<PagingOptions>(Configuration.GetSection("DefaultPagingOptions"));

In RoomsController, add PagingOptions to constructor

        private readonly PagingOptions _defaultPagingOptions; /.../

        public RoomsController(
            /.../
            IOptions<PagingOptions> defaultPagingOptions)
        {
            /.../
            _defaultPagingOptions = defaultPagingOptions.Value;
        }

In RoomsController:GetAllRoomOpeningsAsync, allow defaults if no query params are specified:

            pagingOptions.Offset = pagingOptions.Offset ?? _defaultPagingOptions.Offset;
            pagingOptions.Limit = pagingOptions.Limit ?? _defaultPagingOptions.Limit;

Add validation at beginning of method:

if (!ModelState.IsValid) return BadRequest(new ApiError(ModelState));

In ApiError model, add ApiError overload:

        public ApiError(ModelStateDictionary modelState)
        {
            Message = "Invalid parameters";
            Detail = modelState.FirstOrDefault(x => x.Value.Errors.Any()).Value.Errors.FirstOrDefault().ErrorMessage;
        }

Adding Nav Links

In PagedCollection{T}.cs, add static helper method to extract pagination properties:

            public static PagedCollection<T> Create(
                Link self,
                T[] items,
                int size,
                PagingOptions pagingOptions
            ) => new PagedCollection<T>
            {
                Self = self,
                Value = items,
                Size = size,
                Offset = pagingOptions.Offset,
                Limit = pagingOptions.Limit,
                First = self,
                Next = GetNextLink(self, size, pagingOptions),
                Previous = GetPreviousLink(self, size, pagingOptions),
                Last = GetLastLink(self, size, pagingOptions)
            };

Write GetNext, GetPrevious, and GetLast methods:

        private static Link GetNextLink(Link self, int size, PagingOptions pagingOptions)
        {
            if (pagingOptions?.Limit == null) return null;
            if (pagingOptions?.Offset == null) return null;

            var limit = pagingOptions.Limit.Value;
            var offset = pagingOptions.Offset.Value;

            var next = offset + limit;
            if (next >= size) return null;

            var parameters = new RouteValueDictionary(self.RouteValues)
            {
                ["limit"] = limit,
                ["offset"] = offset
            };

            var newLink = Link.ToCollection(self.RouteName, parameters);
            return newLink;
        }

        private static Link GetLastLink(Link self, int size, PagingOptions pagingOptions)
        {
            if (pagingOptions?.Limit == null) return null;

            var limit = pagingOptions.Limit.Value;

            if (size <= limit) return null;

            var offset = Math.Ceiling((size - (double)limit) / limit) * limit;

            var parameters = new RouteValueDictionary(self.RouteValues)
            {
                ["limit"] = limit,
                ["offset"] = offset
            };
            var newLink = Link.ToCollection(self.RouteName, parameters);

            return newLink;
        }

        private static Link GetPreviousLink(Link self, int size, PagingOptions pagingOptions)
        {
            if (pagingOptions?.Limit == null) return null;
            if (pagingOptions?.Offset == null) return null;

            var limit = pagingOptions.Limit.Value;
            var offset = pagingOptions.Offset.Value;

            if (offset == 0)
            {
                return null;
            }

            if (offset > size)
            {
                return GetLastLink(self, size, pagingOptions);
            }

            var previousPage = Math.Max(offset - limit, 0);

            if (previousPage <= 0)
            {
                return self;
            }

            var parameters = new RouteValueDictionary(self.RouteValues)
            {
                ["limit"] = limit,
                ["offset"] = previousPage
            };
            var newLink = Link.ToCollection(self.RouteName, parameters);

            return newLink;
        }

Add pagination controls to RoomsController:GetAllOpeningsAsync. Replace var collection with revised code:

                var collection = PagedCollection<Opening>.Create(
                    Link.ToCollection(nameof(GetAllRoomOpeningsAsync)),
                    openings.Items.ToArray(),
                    openings.TotalSize,
                    pagingOptions);

ASP.NET API Setup Part 3

Sorting Collections

Extend Model properties that you want to make sortable with [Sortable] attribute. We will create this later.

    public class Room : Resource
    {
        [Sortable]
        public string Name { get; set; }

        [Sortable]
        public decimal Rate { get; set; }
    }

Define the attribute here: Infrastructure/SortableAttribute.cs: Methods ae stubbed for now

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class SortableAttribute : Attribute
    {

    }

Create a model for sort options: Models\SortOptions{T,TEntity}.cs: Methods are stubbed for now:

    public class SortableAttribute<T, TEntity> :IValidatableObject
    {
        public string OrderBy { get; set; }

        // ASP.NET calls this to validate the incoming parameters
        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            throw new NotImplementedException();
        }

        //This will apply sort query to Database call
        public IQueryable<TEntity> Apply(IQueryable<TEntity> query)
        {
            throw new NotImplementedException();
        }
    }

Add query sort parameters to RoomsController:GetRoomsAsync: (also IRoomsService and DefaultRoomService):

            public async Task<IActionResult> GetRoomsAsync(
            [FromQuery] PagingOptions pagingOptions,
            [FromQuery] SortOptions<Room, RoomEntity> sortOptions,
            CancellationToken ct)
            / ... /
            var rooms = await _roomService.GetRoomsAsync(pagingOptions, sortOptions, ct);
            / ... /

To DefaultRoomsService:GetRoomsAsync, add parameters + transfor the query variable like this:

            IQueryable<RoomEntity> query = _context.Rooms;

            query = sortOptions.Apply(query);

Create Infrastructure\SortOptionsProcessor.cs

    public class SortOptionsProcessor<T, TEntity>
    {
        private readonly string[] _orderBy;
        public SortOptionsProcessor(string[] orderBy)
        {
            _orderBy = orderBy;
        }

        public IEnumerable<SortTerm> GetAllTerms()
        {
            throw new NotImplementedException();
        }

Create Infrastructure\SortTerm class:

    public class SortTerm
    {
        public string Name { get; set; }
        public bool Descending { get; set; }
    }

Update SortOptions<T,TE>:Apply:


Create Infrastructure\SortOptionsProcessor{T,TEntity}:

    public class SortOptionsProcessor<T, TEntity>
    {
        private readonly string[] _orderBy;
        public SortOptionsProcessor(string[] orderBy)
        {
            _orderBy = orderBy;
        }

        public IEnumerable<SortTerm> GetAllTerms()
        {
            if (_orderBy == null) yield break;

            foreach(var term in _orderBy)
            {
                if (string.IsNullOrEmpty(term)) continue;

                var tokens = term.Split(' ');

                if (tokens.Length == 0)
                {
                    yield return new SortTerm { Name = term };
                    continue;
                }

                var descending = tokens.Length > 1 && tokens[1].Equals("desc", StringComparison.OrdinalIgnoreCase);

                yield return new SortTerm
                {
                    Name = tokens[0],
                    Descending = descending
                };
            }
        }
    }

In SortOptionsProcessor, add the following reflection:


          private static IEnumerable<SortTerm> GetTermsFromModel()
            => typeof(T).GetTypeInfo()
            .DeclaredProperties
            .Where(p => p.GetCustomAttributes<SortableAttribute>().Any())
            .Select(p => new SortTerm { Name = p.Name });

Add this to SortOptionsProcessor:

        public IEnumerable<SortTerm> GetValidTerms()
        {
            var queryTerms = GetAllTerms().ToArray();
            if (!queryTerms.Any()) yield break;

            var declaredTerms = GetTermsFromModel();

            foreach(var term in queryTerms)
            {
                var declaredTerm = declaredTerms
                    .SingleOrDefault(x => x.Name.Equals(term.Name, StringComparison.OrdinalIgnoreCase));
                if (declaredTerm == null) continue;

                yield return new SortTerm
                {
                    Name = declaredTerm.Name,
                    Descending = term.Descending
                };

            }
        }

Update SortOptions{T,TEntity}:Validate:

        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            var processor = new SortOptionsProcessor<T, TEntity>(OrderBy);

            var validTerms = processor.GetValidTerms().Select(x => x.Name);

            var invalidTerms = processor.GetAllTerms().Select(x => x.Name)
                .Except(validTerms, StringComparer.OrdinalIgnoreCase);

            foreach(var term in invalidTerms)
            {
                yield return new ValidationResult(
                        $"Invalid sort term '{term}'.",
                        new[] { nameof(OrderBy) }
                    );
            }
        }

In Room.cs model, import the Infrastructure namespace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment