Dotnet api linux

From bibbleWiki
Jump to navigation Jump to search

Set up with VS Code

Create Project

To create a webapi using .net core 3.1 simply type

dotnet new webapi

Restore Nuget Packages

dotnet add package Microsoft.EntityFrameworkCore

Install Migration Tool

Exercise called for using Add-Migration on windows. In Linux this translates to

# Install Tool
dotnet add package Microsoft.EntityFrameworkCore.Tools.Dotnet
# Install dotnet-ef
dotnet tool install --global dotnet-ef
# Run Migration Creation
dotnet ef migrations add InitialCreate

Install SQL Server

wget -qO- https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add -

# Add to /etc/apt/source.list
# deb [arch=amd64,arm64,armhf] https://packages.microsoft.com/ubuntu/18.04/mssql-server-2019 bionic main

sudo apt-get update
sudo apt-get install -y mssql-server
sudo /opt/mssql/bin/mssql-conf setup

# Show working
systemctl status mssql-server --no-pager

Create user

CREATE DATABASE test;
GO

CREATE LOGIN test with PASSWORD = 'guess!';
GO

EXEC master..sp_addsrvrolemember @loginame = N'test', @rolename = N'dbcreator'
GO

Running Query

List tables in DB

select schema_name(t.schema_id) as schema_name,
       t.name as table_name,
       t.create_date,
       t.modify_date
from sys.tables t
order by schema_name,
         table_name;
sqlcmd -S localhost -U test -d CourseLibraryDB -Q list_tables.sql

Structuring and Implementing

Interacting with Resources through HTTP Methods

Below is a table which shows the methods and how they work with the Authors and Courses demo app along with suggested naming. Note the use of nouns
Http methods rest.png

Content Negotiation

We also need to ensure that we configure what type of media we support. In ASP .net this can be done by setting the setupAction. This will stop the default of json being returned.

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers(setupAction => {
                setupAction.ReturnHttpNotAcceptable = true;
            }).AddXmlDataContractSerializerFormatters();
...

Method Safety and Method Idempotency

Method safety and idempotency help decide which method to use for which use case.
Method safety idempotency.png

Getting Resources

Intro

When we get resources we generally expect business objects rather than entities. These are known as DTOs (Data Transfer Objects). For C# there are other options but automapper product seems to be the product of choice for ASP .NET.

Adding Automapper

To add automapper

dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

Configure Automapper

And configure the service in the code

        public void ConfigureServices(IServiceCollection services)
        {
...
            services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
...

Create a DTO Class

    public class AuthorDto
    {
        public Guid Id { get; set; }

        public string Name { get; set; }

        public int Age { get; set; }

        public string MainCategory { get; set; }
    }

Create a Profile Class

This class performs the mapping from the Entity to the DTO

public class AuthorsProfile : Profile
{
    public AuthorsProfile()
    {
        CreateMap<Entities.Author, Models.AuthorDto>()
            .ForMember(
               dest => dest.Name,
               opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"))
            .ForMember(
               dest => dest.Age,
               opt => opt.MapFrom(src => src.DateOfBirth.GetCurrentAge()));
        }
    }
}

Implement the Get of collection and instance on the controller

...
        [HttpGet()]
        public ActionResult<IEnumerable<AuthorDto>> GetAuthors()
        {
            var authorsFromRepo = _courseLibraryRepository.GetAuthors();
            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo));
        }
        [HttpGet("{authorId}")]
        public IActionResult GetAuthor(Guid authorId)
        {
            var authorsFromRepo = _courseLibraryRepository.GetAuthor(authorId);
            if (authorsFromRepo == null)
            {
                return NotFound();
            }
            return Ok(_mapper.Map<AuthorDto>(authorsFromRepo));
        }

Overriding Exception Errors

To control the error the consumer sees in the case of an exception, configure an exception handler in Startup.cs

...
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler(appBuilder => {
                    appBuilder.Run(async context => {
                        context.Response.StatusCode = 500;
                        await context.Response.WriteAsync("An unexpected fault occurred. Please try again");
                    });
                });
            }

Supporting HEAD

To support just sending the result without the body simply add HttpHead to the getters.

        [HttpGet()]
        [HttpHead]
        public ActionResult<IEnumerable<AuthorDto>> GetAuthors()
        {
            var authorsFromRepo = _courseLibraryRepository.GetAuthors();
            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo));
        }

Filtering And Searching

Bindings

To get the arguments for filtering and search ASP .NET uses the following

  • [FromQuery] - Gets values from the query string.
  • [FromRoute] - Gets values from route data.
  • [FromForm] - Gets values from posted form fields.
  • [FromBody] - Gets values from the request body.
  • [FromHeader] - Gets values from HTTP headers.

Filtering

Basically add an argument to the get method and implement it on the data repository checking for an argument and returning appropriately. Note the default [FromQuery] argument is the key to this being passed. E.g. mainCategory=Rum on the URI

# In the controller
        [HttpGet()]
        [HttpHead]
        public ActionResult<IEnumerable<AuthorDto>> GetAuthors(string mainCategory)
        {
            var authorsFromRepo = _courseLibraryRepository.GetAuthors(mainCategory);
            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo));
        }

# In the Repository
        public IEnumerable<Author> GetAuthors(string mainCategory)
        {
            if (string.IsNullOrEmpty(mainCategory)) return _context.Authors.ToList<Author>();
            return _context.Authors.Where(x => x.MainCategory == mainCategory.Trim()).ToList();
        }

Searching

To implement this just add a parameter for the searching as you did for filtering and implement appropriately

        public IEnumerable<Author> GetAuthors(string mainCategory, string searchQuery)
        {
            if (string.IsNullOrEmpty(mainCategory) && string.IsNullOrEmpty(searchQuery)) return _context.Authors.ToList<Author>();

            var collection = _context.Authors as IQueryable<Author>;

            if (!string.IsNullOrEmpty(mainCategory))
            {
                collection = collection.Where(x => x.MainCategory == mainCategory.Trim());
            }

            if (!string.IsNullOrEmpty(searchQuery))
            {
                collection = collection.Where(
                    x => x.MainCategory.Contains(searchQuery.Trim()) ||
                         x.FirstName.Contains(searchQuery.Trim()) ||
                         x.LastName.Contains(searchQuery.Trim()));
            }

            return collection.ToList();
        }

Combining Parameters

Make a class to contain these and pass them to the repository.

    public class AuthorsResourceParameters
    {
        public string MainCategory { get; set; }
        public string SearchQuery { get; set; }

    }

You will need to tell the controller where to find them as it is not the default. i.e.

        [HttpGet()]
        [HttpHead]
        public ActionResult<IEnumerable<AuthorDto>> GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
            var authorsFromRepo = _courseLibraryRepository.GetAuthors(authorsResourceParameters);
            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo));
        }

Creating Resources

Create a DTO for creations

    public class AuthorForCreationDto
    {
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public DateTimeOffset DateOfBirth { get; set; }
        public string MainCategory { get; set; }
    }

Create a profile

This converts the DTO to an entity

    public class AuthorsProfile : Profile
    {
       CreateMap<Models.AuthorForCreationDto, API.Entities.Author>();
    }

Implement Post

Add a name to your GET for your entity

        [HttpGet("{authorId}", Name="GetAuthor")]
        public IActionResult GetAuthor(Guid authorId)
        {
...
        }

Most of the code is simple except for the CreateAtRoute. This is used to add a Location header to the response. The Location header specifies the URI of the newly created item.

        [HttpPost]
        public ActionResult<AuthorDto> CreateAuthor(AuthorForCreationDto author)
        {
            var authorEntity = _mapper.Map<Author>(author);
            _courseLibraryRepository.AddAuthor(authorEntity);
            _courseLibraryRepository.Save();

            var authorToReturn = _mapper.Map<AuthorDto>(authorEntity);

            return CreatedAtRoute("GetAuthor",        // the name of the route
                new { authorId = authorToReturn.Id }, // the parameters of the route
                authorToReturn);                      // the DTO to create the DTO from
        }

Creating Resources For a Collection

This is the same as above with the exception of the Location header. If you are creating a collection then the location header will need to support the retrieval of the collection post creation.

Creating the Post Collection Request

This is identical to the single request but using an array.

[HttpPost]
        public ActionResult<IEnumerable<AuthorDto>> CreateAuthorCollections(IEnumerable<AuthorForCreationDto> authorCollection)
        {
            var authorEntities = _mapper.Map<IEnumerable<Author>>(authorCollection);
            foreach (var author in authorEntities)
            {
                _courseLibraryRepository.AddAuthor(author);
            }
            _courseLibraryRepository.Save();

            var authorCollectionToReturn = _mapper.Map<IEnumerable<AuthorDto>>(authorEntities);

            return CreatedAtRoute("GetAuthorCollection",
                new { authorId = string.Join(",", authorCollectionToReturn.Select(x => x.Id)) },
                authorCollectionToReturn);
        }

Creating the Get Collection Request

Everything is the same except you need a [FromRoute] and a ModelBinder. The model binder needs to create and array of composite keys so that all of the items can be retrieved.

        [HttpGet("({authorIds})", Name = "GetAuthorCollection")]
        public IActionResult GetAuthorCollection(
            [FromRoute]
            [ModelBinder(BinderType = typeof(ArrayModelBinder))] IEnumerable<Guid> authorIds)
        {
            if (authorIds == null)
            {
                return BadRequest();
            }

            var authorsFromRepo = _courseLibraryRepository.GetAuthors(authorIds);

            if (authorIds.Count() != authorsFromRepo.Count())
            {
                return NotFound();
            }

            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo));
        }

Creating Array Model Binder

What is Model Binding

What is Model binding Controllers and Razor pages work with data that comes from HTTP requests. For example, route data may provide a record key, and posted form fields may provide values for the properties of the model. Writing code to retrieve each of these values and convert them from strings to .NET types would be tedious and error-prone. Model binding automates this process. The model binding system:

Retrieves data from various sources such as route data, form fields, and query strings. Provides the data to controllers and Razor pages in method parameters and public properties. Converts string data to .NET types. Updates properties of complex types.

My Example of Model Binding

Tried to provide explanation in the comments but basically if checks if sane and then returns an appropriate array of composite keys.

    public class ArrayModelBinder : IModelBinder
    {
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            // Our binder only works on enumerable types
            if(!bindingContext.ModelMetadata.IsEnumerableType)
            {
                bindingContext.Result = ModelBindingResult.Failed();
                return Task.CompletedTask;
            }

            // Get Inputted value from the value provided
            var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString();

            // If the value is null or whitespace, we return null
            if(string.IsNullOrWhiteSpace(value))
            {
                bindingContext.Result = ModelBindingResult.Success(null);
                return Task.CompletedTask;
            }
 
            // If the model is enumerable and 
            //    the value isn't null or whitespace
            //       Get the enumerable types and a converter
            var elementType = bindingContext.ModelMetadata.ModelType.GetTypeInfo().GetGenericArguments()[0];


            var elementTypeConvertor =  TypeDescriptor.GetConverter(elementType);

            // Convert each item in the value list to the enumerable type
            var valuesAsStringArray = value.Split( new[] {","}, StringSplitOptions.RemoveEmptyEntries);
            var values = valuesAsStringArray.Select(x=> elementTypeConvertor.ConvertFrom(x.Trim())).ToArray();
            
            // Create an array of the same length received and the element type in the binding context
            var typedValues = Array.CreateInstance(elementType, values.Length);
            // Copy the string values to to the typed values
            values.CopyTo(typedValues,0);
            // Set the model
            bindingContext.Model = typedValues;

            // Return the result
            bindingContext.Result = ModelBindingResult.Success(bindingContext.Model);
            return Task.CompletedTask;
            
        }
    }

Supporting Options

On our controller we should make sure that the available options are set.

       [HttpOptions]
        public IActionResult GetAuthorsOptions()
        {
            Response.Headers.Add("Allow", "GET,OPTIONS,POST");
            return Ok();
        }

Validation

Intro

Validations should return the correct status code so the user knows how to respond. ASP has built in responses which do the right thing when implemented. By default if not implemented these may be 500.

Validation with Data Annotations

These are provided by the ComponentModel library and you can match the database in your DTOs. E.g.

    public class CourseForCreationDto
    {
        [Required, ErrorMessage="This is required"]
        [MaxLength(100)]     
        public string Title { get; set; }

        [MaxLength(1500)]        
        public string Description { get; set; }
    }
}

Validation at Class level

You can implement this using the IValidatableObject interface. Note this does not run by default if the Data Annotations fail.

    public class CourseForCreationDto : IValidatableObject
    {
...
        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            if (Title == Description)
            {
                yield return new ValidationResult(
                "The provided description should be different from the title.",
                new[] { "CoursesForCreationDta" });
            }
        }    
    }

Custom Validation at Class level

We can separate the validation into a class and use a decorator to do the same job. i.e. putting [CourseTitleMustBeDifferentFromDescriptionAttribute] against the class.

    public class CourseTitleMustBeDifferentFromDescriptionAttribute : ValidationAttribute
    {
        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            var course = (CourseForCreationDto)validationContext.ObjectInstance;

            if (course.Title == course.Description)
            {
                return new ValidationResult(
                "The provided description should be different from the title.",
                new[] { "CoursesForCreationDta" });
            }

            return ValidationResult.Success;
        }
    }

Customising the validation error responses

This probably needs revisiting. I think we need to make sure the problem details are appropriate for the audience. Here is the example provided.

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers(setupAction =>
            {
                setupAction.ReturnHttpNotAcceptable = true;
            }).AddXmlDataContractSerializerFormatters().ConfigureApiBehaviorOptions(
                setupAction =>
                {
                    setupAction.InvalidModelStateResponseFactory = context =>
                    {
                        // Create problem details object
                        var problemDetailsFactory = context.HttpContext.RequestServices.GetRequiredService<ProblemDetailsFactory>();
                        var problemDetails = problemDetailsFactory.CreateValidationProblemDetails(
                            context.HttpContext,
                            context.ModelState);

                        // Add addtional information
                        problemDetails.Detail = "See the errors field for details";
                        problemDetails.Instance = context.HttpContext.Request.Path;

                        var actionExecutingContext = context as Microsoft.AspNetCore.Mvc.Filters.ActionExecutingContext;
                        // If error count > 0
                        // and all arguments were found and parsed
                        //    This is a validation error
                        if (context.ModelState.ErrorCount > 0 &&
                           actionExecutingContext?.ActionArguments.Count == context.ActionDescriptor.Parameters.Count)
                        {
                            problemDetails.Type = "https://courselibrary.com/modelvalidationerror";
                            problemDetails.Status = StatusCodes.Status422UnprocessableEntity;
                            problemDetails.Title = "One or more validation errors occurred";

                            return new UnprocessableEntityObjectResult(problemDetails)
                            {
                                ContentTypes = { "application/problem+json" }
                            };
                        }

                        // If one of the arguments was not found or could not be parsed
                        //    This is bad input
                        problemDetails.Status = StatusCodes.Status400BadRequest;
                        problemDetails.Title = "One or more errors on input occurred";
                        return new UnprocessableEntityObjectResult(problemDetails)
                        {
                            ContentTypes = { "application/problem+json" }
                        };

                    };
                });
...

FluentValidation

This was a library recommended on the course.

Updating Resources

Using PUT

This is almost identical to post but does not require you to return content. The Automapper is handy to do the DTO->Entity->DTO

        [HttpPut("{courseId}")]
        public ActionResult UpdateCourse(Guid authorId, Guid courseId, CourseForUpdateDto course)
        {
            if (_courseLibraryRepository.AuthorExists(authorId) == false) return NotFound();

            var courseForAuthorFromRepo = _courseLibraryRepository.GetCourse(authorId, courseId);
            if (courseForAuthorFromRepo == null) return NotFound();

            // map the entity to the courseForUpdate
            // apply the updated field values to that dto
            // map the CourseForUpdateDto back to an entity
            _mapper.Map(course, courseForAuthorFromRepo);

            _courseLibraryRepository.UpdateCourse(courseForAuthorFromRepo);
            _courseLibraryRepository.Save();

            return NoContent();
        }

Json Patch Operations

Intro

A JSON Patch document is just a JSON file containing an array of patch operations. The patch operations supported by JSON Patch are “add”, “remove”, “replace”, “move”, “copy” and “test”. The operations are applied in order: if any of them fail then the whole patch operation should abort.

Add

{ "op": "add", "path": "/biscuits/1", "value": { "name": "Ginger Nut" } }

Adds a value to an object or inserts it into an array. In the case of an array, the value is inserted before the given index. The - character can be used instead of an index to insert at the end of an array.

Remove

{ "op": "remove", "path": "/biscuits" }

Removes a value from an object or array.

{ "op": "remove", "path": "/biscuits/0" }

Removes the first element of the array at biscuits (or just removes the “0” key if biscuits is an object)

Replace

{ "op": "replace", "path": "/biscuits/0/name", "value": "Chocolate Digestive" }

Replaces a value. Equivalent to a “remove” followed by an “add”.

Copy

{ "op": "copy", "from": "/biscuits/0", "path": "/best_biscuit" }

Copies a value from one location to another within the JSON document. Both from and path are JSON Pointers.

Move

{ "op": "move", "from": "/biscuits", "path": "/cookies" }

Moves a value from one location to the other. Both from and path are JSON Pointers.

Test

{ "op": "test", "path": "/best_biscuit/name", "value": "Choco Leibniz" }

Tests that the specified value is set in the document. If the test fails, then the patch as a whole should not apply.

Performing a Patch update

C# Code

We need to add the library to support json patch to the project

dotnet add package Microsoft.AspNetCore.JsonPatch

The code to apply a patch is similar to the code to apply an update

        [HttpPatch("{courseId}")]
        public ActionResult PartiallyUpdateCourseForAuthor(
            Guid authorId,
            Guid courseId,
            JsonPatchDocument<CourseForUpdateDto> patchDocument)
        {
            // Check Author Exists              
            if (_courseLibraryRepository.AuthorExists(authorId) == false) return NotFound();

            // Check Course Exists              
            var courseForAuthorFromRepo = _courseLibraryRepository.GetCourse(authorId, courseId);
            if (courseForAuthorFromRepo == null)
            {
                return NotFound();
            }

            var courseToPatch = _mapper.Map<CourseForUpdateDto>(courseForAuthorFromRepo);

            patchDocument.ApplyTo(courseToPatch, ModelState);

            if (!TryValidateModel(courseToPatch))
            {
                return ValidationProblem(ModelState);
            }

            _mapper.Map(courseToPatch, courseForAuthorFromRepo);

            _courseLibraryRepository.UpdateCourse(courseForAuthorFromRepo);
            _courseLibraryRepository.Save();
            return NoContent();
       }

Microsoft Json Parser

This parser is not a proficient as the newton parser so we need to add that to the project

dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson

We need to also add this to the startup code.

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllers(setupAction =>
            {
                setupAction.ReturnHttpNotAcceptable = true;
            })
...
            .AddXmlDataContractSerializerFormatters()
            .AddNewtonsoftJson(setupAction => 
            {
                setupAction.SerializerSettings.ContractResolver = 
                     new CamelCasePropertyNamesContractResolver();
            })
...
            .ConfigureApiBehaviorOptions(

Note the ordering of the statements determines which format the default is. So in the case we need to swap the statement around to have json first e.g.

            .AddNewtonsoftJson(setupAction => 
            {
                setupAction.SerializerSettings.ContractResolver = 
                     new CamelCasePropertyNamesContractResolver();
            })
            .AddXmlDataContractSerializerFormatters()

Validation

We can override the validation to point back to the ApiBehaviourOptions we created above.

        public override ActionResult ValidationProblem(
            [ActionResultObjectValue] ModelStateDictionary modelStateDictionary)
        {
            var options = HttpContext.RequestServices.GetRequiredService<IOptions<ApiBehaviorOptions>>();
            return (ActionResult)options.Value.InvalidModelStateResponseFactory(ControllerContext);
        }

Upserting for Patch

This is the same as upserting for Put. If it does not exist then create it.

            // Check Course Exists              
            var courseForAuthorFromRepo = _courseLibraryRepository.GetCourse(authorId, courseId);
            if (courseForAuthorFromRepo == null)
            {
                var courseDto = new  CourseForUpdateDto();

                patchDocument.ApplyTo(courseDto, ModelState);

                if (!TryValidateModel(courseDto))
                {
                    return ValidationProblem(ModelState);
                }

                var courseToAdd = _mapper.Map<Course>(courseDto);
                courseToAdd.Id = courseId;

                // As per HttpPut
                _courseLibraryRepository.AddCourse(authorId, courseToAdd);
                _courseLibraryRepository.Save();

                var courseToReturn = _mapper.Map<CourseDto>(courseToAdd);

                return CreatedAtRoute("GetCoursesForAuthor",
                    new { authorId = authorId, courseId = courseToReturn.Id },
                    courseToReturn);
            }

Deleting Resources

This is very straight forward.

        [HttpDelete("{courseId}")]
        public IActionResult DeleteCourseForAuthor(Guid authorId, Guid courseId)
        {
            if (_courseLibraryRepository.AuthorExists(authorId) == false) return NotFound();

            var courseForAuthorFromRepo = _courseLibraryRepository.GetCourse(authorId, courseId);

            if (courseForAuthorFromRepo == null) return NotFound();

            _courseLibraryRepository.DeleteCourse(courseForAuthorFromRepo);
            _courseLibraryRepository.Save();

            return NoContent();
        }

RESTFul Advanced Topics

Paging

Deferred Execution

Deferred queries are expression which are not evaluated until the query is used. This allows the user to continue to add constraints without taking a performance hit. e.g.

var collection = _context.Authors as IQueryable<Author>;
collection = collection.Where(x => x.MainCategory == myMainCategory);             }

Paging through Collection Resources

  • Parameters can be passed via query string e.g.
http://host/api/authors?pageNumber=1&pageSize=5
  • Page size should be limited
  • Page by default

Implement Helper Class ForPaging

    public class PagedList<T> : List<T>
    {
        public int CurrentPage { get; private set; }
        public int TotalPages { get; private set; }
        public int PageSize { get; private set; }
        public int TotalCount { get; private set; }
        public bool HasPrevious => (CurrentPage > 1);
        public bool HasNext => (CurrentPage < TotalPages);

        public PagedList(List<T> items, int count, int pageNumber, int pageSize)
        {
            TotalCount = count;
            PageSize = pageSize;
            CurrentPage = pageNumber;
            TotalPages = (int)System.Math.Ceiling(count / (double)pageSize);
            AddRange(items);
        }

        // Factory
        public static PagedList<T> Create(IQueryable<T> source, int pageNumber, int pageSize)
        {
            var count = source.Count();
            var items = source.Skip((pageNumber -1) * pageSize).Take(pageSize).ToList();
            return new PagedList<T>(items,count,pageNumber,pageSize);
        }
    }

Implementing Paging

This easy using the take and skip provided by IQueryTable. Add this to the Get method.

return collection
 .Skip(authorsResourceParameters.PageSize * (authorsResourceParameters.PageNumber - 1))
 .Take(authorsResourceParameters.PageSize)
 .ToList();

Returning Pagination Data in ASP .Net

Introduction

To Do this we need to

  • Change repository to return PagedList
  • Create enumerator for previous and next page
  • Build Uri Based on previous or next page
  • Change GET to implement pagination
    • Create previous link
    • Create next link
    • Create Pagination data
    • Add it to the headers

Change repository to return PagedList

        public PagedList<Author> GetAuthors(AuthorsResourceParameters authorsResourceParameters)
        {
            if (string.IsNullOrEmpty(authorsResourceParameters.MainCategory) && string.IsNullOrEmpty(authorsResourceParameters.SearchQuery)) return _context.Authors.ToList<Author>();

....
            return PagedList<Author>.Create(collection,
                authorsResourceParameters.PageNumber,
                authorsResourceParameters.PageSize);

Create Enum for Previous and Next Page

public enum ResourceUriType
{
    PreviousPage,
    NextPage,
    CurrentPage
}

Build Uri Based on previous or next page

        private string CreateAuthorsResourceUri(
            AuthorsResourceParameters authorsResourceParameters,
            ResourceUriType resourceUriType)
        {
            switch (resourceUriType)
            {
                case ResourceUriType.PreviousPage:
                    return Url.Link("GetAuthors",
                        new
                        {
                            pageNumber = authorsResourceParameters.PageNumber - 1,
                            pageSize = authorsResourceParameters.PageSize,
                            mainCategory = authorsResourceParameters.MainCategory,
                            searchQuery = authorsResourceParameters.SearchQuery,
                            orderBy = authorsResourceParameters.OrderBy,
                            fields = authorsResourceParameters.Fields,
                        });

                case ResourceUriType.NextPage:
                    return Url.Link("GetAuthors",
                        new
                        {
                            pageNumber = authorsResourceParameters.PageNumber + 1,
                            pageSize = authorsResourceParameters.PageSize,
                            mainCategory = authorsResourceParameters.MainCategory,
                            searchQuery = authorsResourceParameters.SearchQuery,
                            orderBy = authorsResourceParameters.OrderBy,
                            fields = authorsResourceParameters.Fields,
                        });
                case ResourceUriType.CurrentPage:
                default:
                    return Url.Link("GetAuthors",
                        new
                        {
                            pageNumber = authorsResourceParameters.PageNumber,
                            pageSize = authorsResourceParameters.PageSize,
                            mainCategory = authorsResourceParameters.MainCategory,
                            searchQuery = authorsResourceParameters.SearchQuery,
                            orderBy = authorsResourceParameters.OrderBy,
                            fields = authorsResourceParameters.Fields,
                        });
            }

Change GET To Implement Pagination

        [HttpGet(Name = "GetAuthors")]
        [HttpHead]
        public ActionResult<IEnumerable<AuthorDto>> GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
            var authorsFromRepo = _courseLibraryRepository.GetAuthors(authorsResourceParameters);

            var previousPageLink = authorsFromRepo.HasPrevious ? 
                CreateAuthorsResourceUri(
                    authorsResourceParameters,
                    ResourceUriType.PreviousPage) : null;

            var nextPageLink = authorsFromRepo.HasNext ? 
                CreateAuthorsResourceUri(
                    authorsResourceParameters,
                    ResourceUriType.NextPage) : null;

            var paginationMetaData = new 
            {
                totalCount = authorsFromRepo.TotalCount,
                pageSize = authorsFromRepo.PageSize,
                currentPage = authorsFromRepo.CurrentPage,
                totalPages = authorsFromRepo.TotalPages,
                previousPageLink,
                nextPageLink
            };

            Response.Headers.Add("X-Pagination",
                JsonSerializer.Serialize(paginationMetaData));

            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo));
        }

Sorting

Add Dynamic Linq

dotnet add package System.Linq.Dynamic.Core

Property Mapping Service

The course suggested a property mapping service which allows a DTO name to be mapped to 1-n number of fields on the entity. It also suggested it should encapulate whether the mapper needed to be reversed. E.g. Mapping Age to DateOfBirth means we would want them in reverse order. Here was an overview of design

PropertyMappingService : IPropertyMappingService
  IList<IPropertyMapping> propertyMappings  e.g. AuthorDto to Author
     PropertyMapping<TSource, TDestination> : IPropertyMapping
         Dictionary<string, PropertyMappingValue>
             PropertyMappingValue
                 DestinationProperties      e.g. FirstName, LastName
                 Revert                     e.g. true for Age => DateOfBirth

  GetPropertyMapping<TSource, TDestination>() e.g. from AuthorDto to Author

Property Mapping Service Implementation

Property Mapping Value

    public class PropertyMappingValue
    {
        public IEnumerable<string> DestinationProperties { get; private set; }
        public bool Revert { get; private set; }

        public PropertyMappingValue(
            IEnumerable<string> destinationProperties,
            bool revert = false)
        {
            DestinationProperties = destinationProperties
                ?? throw new ArgumentNullException(nameof(destinationProperties));
            Revert = revert;
        }
    }

Property Mapping

Create an interface

    public interface IPropertyMapping
    {
    }

And create an implementation of the interface

    public class PropertyMapping<TSource, TDestination> : IPropertyMapping
    {
        public Dictionary<string, PropertyMappingValue> _mappingDictionary {get; private set;}

        public PropertyMapping(Dictionary<string, PropertyMappingValue> mappingDictionary)
        {   
            _mappingDictionary = mappingDictionary ?? 
                throw new ArgumentNullException(nameof(mappingDictionary));
        }
    }

Property Mapping Service

Create an interface

    public interface IPropertyMappingService
    {
        Dictionary<string, PropertyMappingValue> GetPropertyMapping<TSource, TDestination>();
    }

And create an implementation of the interface

    public class PropertyMappingService : IPropertyMappingService
    {
        private Dictionary<string, PropertyMappingValue> _authorPropertyMapping =
            new Dictionary<string, PropertyMappingValue>(StringComparer.OrdinalIgnoreCase)
            {
                {"Id", new PropertyMappingValue(new List<string>() {"Id"}) },
                {"MainCategory", new PropertyMappingValue(new List<string>() {"MainCategory"}) },
                {"Age", new PropertyMappingValue(new List<string>() {"DateOfBirth"},true) },
                {"Name", new PropertyMappingValue(new List<string>() {"FirstName", "LastName"}) }
            };

        private IList<IPropertyMapping> _propertyMappings =  new List<IPropertyMapping>();            

        public PropertyMappingService()
        {
            _propertyMappings.Add(new PropertyMapping<AuthorDto,Author>(_authorPropertyMapping));
        }

        public Dictionary<string, PropertyMappingValue> GetPropertyMapping<TSource, TDestination>()
        {
            var matchingMapping = _propertyMappings.OfType<PropertyMapping<TSource,TDestination>>();

            if(matchingMapping.Count() == 1)
            {
                return matchingMapping.First()._mappingDictionary;
            }

            throw new Exception($"Cannot find exact property mapping instance " +
                $"for <{typeof(TSource)},{typeof(TDestination)}>");
        }
    }

Add Service to the Container

Under Startup

            services.AddTransient<IPropertyMappingService, PropertyMappingService>();

Change Repository to use Mapping Service

Add it to the constructor of the repository and in the GetAuthors method add the OrderBy code as the last step.

        public PagedList<Author> GetAuthors(AuthorsResourceParameters authorsResourceParameters)
        {
...
            if (!string.IsNullOrWhiteSpace(authorsResourceParameters.OrderBy))
            {
               
                // Get property mapping dictionary
                var authorPropertyMappingDictionary = _propertyMappingService.GetPropertyMapping<AuthorDto,Author>();
                collection = collection.ApplySort(
                    authorsResourceParameters.OrderBy, 
                    authorPropertyMappingDictionary);
            }

Create Extension Method on IQueryable

This does most of the work. It validates the arguments, extracts the properties and builds the orderby clause.

    public static class IQueryableExtensions
    {
        public static IQueryable<T> ApplySort<T>(
            this IQueryable<T> source,
            string orderBy,
            Dictionary<string, PropertyMappingValue> mappingDictionary)
        {
            var orderByString = "";

            // Validate input
            if (source == null) throw new ArgumentNullException(nameof(source));
            if (mappingDictionary == null) throw new ArgumentNullException(nameof(mappingDictionary));
            if (string.IsNullOrWhiteSpace(orderBy)) return source;

            // Split the orderby up
            var orderByAfterSplit = orderBy.Split(",");

            // Apply order by clause in reverse order otherwise the 
            // IQueryable will be ordered in wrong order
            foreach (var orderByClause in orderByAfterSplit.Reverse())
            {
                // Trim
                var trimmedOrderByClause = orderByClause.Trim();

                // Check for direction
                var orderByDescending = trimmedOrderByClause.EndsWith(" desc");

                // Get the property name
                //   Remove asc or desc 
                var indexOfFirstSpace = trimmedOrderByClause.IndexOf(" ");
                var propertyName = indexOfFirstSpace == -1 ?
                        trimmedOrderByClause :
                        trimmedOrderByClause.Remove(indexOfFirstSpace);

                // Check in the mapping dictionary
                if (!mappingDictionary.ContainsKey(propertyName))
                    throw new ArgumentException($"Key Mapping for Property Name {propertyName} is missing.");

                var propertyMappingValue = mappingDictionary[propertyName];

                // Check value is not null
                if (propertyMappingValue == null)
                    throw new Exception($"Property Mapping Value {propertyMappingValue} is null");

                // Iterate over the property names
                // so the orderby clauses are applied in the correct order
                foreach (var destinationProperty in propertyMappingValue.DestinationProperties)
                {
                    // Revert sort order if necessary
                    if (propertyMappingValue.Revert) orderByDescending = !orderByDescending;

                    orderByString = orderByString +
                        (string.IsNullOrWhiteSpace(orderByString) ?
                            string.Empty :
                            ", ") +
                            destinationProperty +
                            (orderByDescending ?
                            " descending" :
                            " ascending");
                }
            }

            return source.OrderBy(orderByString);
        }
    }

Validate the OrderBy

We should check the orderby is a correct request to ensure the correct status code is returned. Lets

  • Create a function on the service
  • Implement on the controller

Create a function on the service

Add to interface and implement in the service

        public bool ValidMappingExistsFor<TSource, TDestination>(string fields)
        {
            var propertyMapping = GetPropertyMapping<TSource, TDestination>();

            if (string.IsNullOrWhiteSpace(fields)) return true;

            var splitFields = fields.Split(",");

            foreach (var splitField in splitFields)
            {
                var trimmedSplitField = splitField.Trim();

                // Remove everything after " " e.g. asc 
                var indexOfSpace = trimmedSplitField.IndexOf(" ");
                var propertyName = indexOfSpace == -1 ?
                    trimmedSplitField :
                    trimmedSplitField.Remove(indexOfSpace);

                // Find matching property 
                if (!propertyMapping.ContainsKey(propertyName)) return false;
            }
            return true;
        }

Implement on the Controller

Checks the mapping and returns Bad Request on failure.

        [HttpGet(Name = "GetAuthors")]
        [HttpHead]
        public ActionResult<IEnumerable<AuthorDto>> GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
            if (!_propertyMappingService.ValidMappingExistsFor<AuthorDto, Author>(authorsResourceParameters.OrderBy))
            {
                return BadRequest();
            }
....

Shaping

Shaping

Shaping is when you want a selection of the data and not the whole resource. E.g. For an author you only want the Id and Name but not the date of birth. To achieve this we can create a Helper function on IEnumerable using ExpandoObject

Implementation on Collection

We create a helper class which takes the whole result as input and returns only the fields required as the result using reflection.

        public static IEnumerable<ExpandoObject> ShapeData<TSource>(
            this IEnumerable<TSource> source,
            string fields)
        {
            if (source == null) throw new ArgumentNullException(nameof(source));

            // Create ExpandoObject to store result
            var expandoObjectList = new List<ExpandoObject>();

            // Create a list of PropertyInfos for source. Reflection is expensive
            // so rather than doing it for each object, we do it once and resue
            var propertyListInfo = new List<PropertyInfo>();

            if (string.IsNullOrWhiteSpace(fields))
            {
                // all public properties should be in the expandoObject
                var propertyInfos = typeof(TSource).GetProperties(BindingFlags.PutDispProperty | BindingFlags.Instance);
                propertyListInfo.AddRange(propertyInfos);
            }
            else
            {
                var fieldsAfterSplit = fields.Split(",");

                foreach (var field in fieldsAfterSplit)
                {
                    // Trim
                    var propertyName = field.Trim();

                    // use reflection to get the property name and value
                    var propertyInfo = typeof(TSource).GetProperty(
                        propertyName,
                        // we need to include public and instance, b/c specifying a 
                        // binding flag overwrites the already-existing binding flags.
                        BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);

                    if (propertyInfo == null)
                        throw new Exception($"Property {propertyName} wasn't found on {typeof(TSource)}");

                    propertyListInfo.Add(propertyInfo);
                }
            }

            // Now lets create the ExpandoObject for each item in TSource
            foreach (TSource sourceObject in source)
            {
                // Create expandoObject
                var dataShapeObject = new ExpandoObject();

                // Get the value for each Property we have to return
                foreach (var propertyInfo in propertyListInfo)
                {
                    // Get the value of the property 
                    var propertyValue = propertyInfo.GetValue(sourceObject);

                    // Add the field to the ExpandoObject
                    ((IDictionary<string, object>)dataShapeObject).Add(propertyInfo.Name, propertyValue);
                }
                expandoObjectList.Add(dataShapeObject);
            }
            return expandoObjectList;
        }

Don't forget to implement this on the controller by changing the GetAuthors to return an IActionResult and to call the ShapeData extension method.

        [HttpGet(Name = "GetAuthors")]
        [HttpHead]
        public IActionResult GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
...
            return Ok(_mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo).ShapeData(authorsResourceParameters.Fields));
        }

Implementation on Object

Helper to build the Shape Data

This is required because reflection is very expensive. Suspect we could have a shared helper class to combine these.

        public static ExpandoObject ShapeData<TSource>(
            this TSource source,
            string fields)
        {
            if (source == null) throw new ArgumentNullException(nameof(source));

            var dataShapedObject = new ExpandoObject();

            if (string.IsNullOrWhiteSpace(fields))
            {
                // all public properties should be in the ExpandoObject 
                var propertyInfos = typeof(TSource)
                        .GetProperties(BindingFlags.IgnoreCase |
                        BindingFlags.Public | BindingFlags.Instance);

                foreach (var propertyInfo in propertyInfos)
                {
                    // get the value of the property on the source object
                    var propertyValue = propertyInfo.GetValue(source);

                    // add the field to the ExpandoObject
                    ((IDictionary<string, object>)dataShapedObject)
                        .Add(propertyInfo.Name, propertyValue);
                }

                return dataShapedObject;
            }

            // the field are separated by ",", so we split it.
            var fieldsAfterSplit = fields.Split(',');

            foreach (var field in fieldsAfterSplit)
            {
                // Trim
                var propertyName = field.Trim();

                // use reflection to get the property name and value
                var propertyInfo = typeof(TSource)
                    .GetProperty(propertyName,
                    // we need to include public and instance, b/c specifying a 
                    // binding flag overwrites the already-existing binding flags.
                    BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);

                // If not found error
                if (propertyInfo == null)
                    throw new Exception($"Property {propertyName} wasn't found on {typeof(TSource)}");

                // get the value of the property on the source object
                var propertyValue = propertyInfo.GetValue(source);

                // add the field to the ExpandoObject
                ((IDictionary<string, object>)dataShapedObject)
                    .Add(propertyInfo.Name, propertyValue);
            }

            // return the list
            return dataShapedObject;
        }

Service to Test the Property Names are Valid

This can be put in a transient service and injected into the controller. I checks the type passed to ensure the field names requested exist in the class name requested.

        public bool TypeHasProperties<T>(string fields)
        {
            if (fields == null) return true;

            // Split fields
            var fieldsAfterSplit = fields.Split(",");

            foreach (var field in fieldsAfterSplit)
            {
                var propertyName = field.Trim();

                var propertyInfo = typeof(T)
                    .GetProperty(propertyName,
                    BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);

                if (propertyInfo == null) return false;

            }

            return true;
        }

Implement in the Controller

Add the Service to the startup and controller. Call when requesting a AuthorDto. This ensures that the correct status is returned. Not checking will mean an exception is thrown which is a 500.

        [HttpGet(Name = "GetAuthors")]
        [HttpHead]
        public IActionResult GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
            if (!_propertyMappingService.ValidMappingExistsFor<AuthorDto, Author>(authorsResourceParameters.OrderBy))
            {
                return BadRequest();
            }

            if (!_propertyCheckerService.TypeHasProperties<AuthorDto>(authorsResourceParameters.Fields))
            {
                return BadRequest();
            }

Additional Options

You could add the following to the API Include Child Resources

Advanced Filters

HATEOAS

Introduction

HATEOAS (Hypermedia as the Engine of Application State)

  • Helps the evolvability and self-descriptiveness
  • Drives how to consume and use the API

You can't have evolvability if clients have their controls baked into their design at deployment. Controls have to be learned on the fly. That's what hypermedia enables. - Roy Fielding

Supporting HATEOAS Example

To support this for our Authors example we want to provide links to

  • GET Author
  • DELETE Author
  • POST Course
  • GET Courses

Create Class to hold links

Nothing to see here

    public class LinkDto
    {
        public string Href {get; private set;}
        public string Rel {get; private set;}
        public string Method {get; private set;}
        public LinkDto(string href, string rel, string method)
        {
            Href = href;
            Rel = rel;
            Method = method;
        }
    }

Add Generation of Links to Controller

Make sure the methods are labelled appropriately and build the links into a list

        public IEnumerable<LinkDto> CreateLinkForAuthor(Guid authorId, string fields)
        {
            var links = new List<LinkDto>();

            if (string.IsNullOrWhiteSpace(fields))
            {
                links.Add(new LinkDto(
                    Url.Link("GetAuthor", new { authorId }),
                    "self",
                    "GET"));
            }
            else
            {
                links.Add(new LinkDto(
                    Url.Link("GetAuthor", new { authorId, fields }),
                    "self",
                    "GET"));
            }

            links.Add(new LinkDto(
                Url.Link("DeleteAuthor", new { authorId }),
                "delete_author",
                "DELETE"));

            links.Add(new LinkDto(
                Url.Link("CreateCourseForAuthor", new { authorId }),
                "create_course",
                "POST"));

           links.Add(new LinkDto(
                Url.Link("GetCoursesForAuthor", new { authorId }),
                "get_courses",
                "GET"));

            return links;
        }

Amend GetAuthor to Add Links

ShapedData is a Dictionary of string, Object. Let's put the links into this for GetAuthors

        public IActionResult GetAuthor(Guid authorId, string fields)
        {
...
            var links = CreateLinkForAuthor(authorId, fields);

            var linkedResourcesToReturn =
                _mapper.Map<AuthorDto>(authorsFromRepo).ShapeData(fields) as
                IDictionary<string, object>;

            linkedResourcesToReturn.Add("links", links);

            return Ok(linkedResourcesToReturn);
        }

Result

Here is an example of the code. GetAuthor HATEOAS.png

Supporting HATEOAS For Create

Simple amend the CreateAuthors to use the already created code.

        [HttpPost]
        public ActionResult<AuthorDto> CreateAuthor(AuthorForCreationDto author)
        {
...

            var links = CreateLinkForAuthor(authorToReturn.Id, null);

            var linkedResourcesToReturn =
                _mapper.Map<AuthorDto>(authorToReturn).ShapeData(null) as
                IDictionary<string, object>;

            linkedResourcesToReturn.Add("links", links);

            return CreatedAtRoute("GetAuthor",
                new { authorId = linkedResourcesToReturn["Id"] },
                linkedResourcesToReturn);
        }

Supporting HATEOAS For A Collection

Create Links for Authors

We need to create links for authors

        public IEnumerable<LinkDto> CreateLinksForAuthors(
            AuthorsResourceParameters authorsResourceParameters)
        {
            var links = new List<LinkDto>();

            links.Add(
                new LinkDto(CreateAuthorsResourceUri(
                    authorsResourceParameters,
                    ResourceUriType.CurrentPage),
                "self",
                "GET"));

            return links;
        }

Change GetAuthors to use this

Creating of the links for each author is the same as the GetAuthor.

        [HttpGet(Name = "GetAuthors")]
        [HttpHead]
        public IActionResult GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
...
            Response.Headers.Add("X-Pagination",
                JsonSerializer.Serialize(paginationMetaData, options));

            // Create Links for Get Authors
            var links = CreateLinksForAuthors(authorsResourceParameters);

            // Get the authors shaped
            var shapedAuthors = _mapper.Map<IEnumerable<AuthorDto>>(authorsFromRepo)
                .ShapeData(authorsResourceParameters.Fields);

            // For each author add the links
            var shapedAuthorsWithLinks = shapedAuthors.Select(author =>
            {
                // Make easier to work with by converting to Dictionary as before
                var authorAsDictionary = author as IDictionary<string, object>;

                // Create Links
                var authorLinks = CreateLinkForAuthor((Guid)authorAsDictionary["Id"], null);

                // Add to Dictionary
                authorAsDictionary.Add("Links", authorLinks);

                return authorAsDictionary;
            });

            // Put the links and the shapedAuthors with Links in a object
            var linkedCollectionResource = new 
            {
                value = shapedAuthorsWithLinks,
                links
            };

            return Ok(linkedCollectionResource);
        }

Supporting HATEOAS for Pagination Links

We should remove the pagination links in custom header and replace them with HATEOAS links. To do this we will

  • Change CreateLinksForAuthors to add links for next and previous

Change CreateLinksForAuthors

        public IEnumerable<LinkDto> CreateLinksForAuthors(
            AuthorsResourceParameters authorsResourceParameters,
            bool HasNext,
            bool HasPrevious)
        {
...
            if(HasNext)
            {
                links.Add(
                    new LinkDto(CreateAuthorsResourceUri(
                        authorsResourceParameters,
                        ResourceUriType.NextPage),
                    "nextPage",
                    "GET"));
            }

            if(HasPrevious)
            {
                links.Add(
                    new LinkDto(CreateAuthorsResourceUri(
                        authorsResourceParameters,
                        ResourceUriType.PreviousPage),
                    "previousPage",
                    "GET"));
            }
       }

Change GetAuthors

Remove previousPageLink and nextPageLink from metadata and add the values to the CreateLinksForAuthors.

        [HttpGet(Name = "GetAuthors")]
        [HttpHead]
        public IActionResult GetAuthors([FromQuery] AuthorsResourceParameters authorsResourceParameters)
        {
...
            var paginationMetaData = new
            {
                totalCount = authorsFromRepo.TotalCount,
                pageSize = authorsFromRepo.PageSize,
                currentPage = authorsFromRepo.CurrentPage,
                totalPages = authorsFromRepo.TotalPages,
            };

            var options = new System.Text.Json.JsonSerializerOptions()
            {
                Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
            };

            Response.Headers.Add("X-Pagination",
                JsonSerializer.Serialize(paginationMetaData, options));

            // Create Links for Get Authors
            var links = CreateLinksForAuthors(
                    authorsResourceParameters,
                    authorsFromRepo.HasNext,
                    authorsFromRepo.HasPrevious);
...

Adding a Root Document

Just add all links from the root the user can access

   public class RootController : ControllerBase
    {
        [HttpGet(Name = "GetRoot")]
        public IActionResult GetRoot()
        {
            // Create links for root
            var links = new List<LinkDto>();

            links.Add(
                new LinkDto(
                    Url.Link("GetRoot", new { }),
                    "self",
                    "GET"));

            links.Add(
                new LinkDto(
                    Url.Link("GetAuthors", new { }),
                    "authors",
                    "GET"));

            links.Add(
                new LinkDto(
                    Url.Link("CreateAuthor", new { }),
                    "create_author",
                    "POST"));

            return Ok(links);
        }
    }

Other Approaches and Opetions

Content Negotiation

Introduction

In the examples above we added links to the output. But this is probably not desired for every call. We should create a media type and only return this information when the correct media type is requested.

Vendor Specific Media Types

Below is an example of how to name a vendor specific media type. Mediatype restful.png

Implementation

This is simple enough.

  • Change Startup to
    • Remove the original Output formatter
    • Accept new media type
  • Change the controller to
    • Validate the media type
    • Return the appropriate data

Remove the original Output formatter

            services.AddControllers(setupAction =>
            {
                setupAction.ReturnHttpNotAcceptable = true;
            })

Accept new media type

            services.Configure<MvcOptions>(config =>
            {
                var newtownSoftJsonOutputFormatter = config.OutputFormatters
                    .OfType<Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter>()?.FirstOrDefault();

                if (newtownSoftJsonOutputFormatter != null)
                {
                    newtownSoftJsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.marvin.hateoas+json");
                }
            });

Validate the media type

        [HttpGet("{authorId}", Name = "GetAuthor")]
        public IActionResult GetAuthor(Guid authorId, string fields,
        [FromHeader(Name ="Accept")] string mediaType)
        {
            if(!MediaTypeHeaderValue.TryParse(mediaType, out MediaTypeHeaderValue parsedMediaType))
            {
                return BadRequest();
            }
...

Return the appropriate data

            if (parsedMediaType.MediaType == "application/vnd.marvin.hateoas+json")
            {
                var links = CreateLinkForAuthor(authorId, fields);

                var linkedResourcesToReturn =
                    _mapper.Map<AuthorDto>(authorsFromRepo).ShapeData(fields) as
                    IDictionary<string, object>;

                linkedResourcesToReturn.Add("links", links);

                return Ok(linkedResourcesToReturn);
            }

            return Ok(_mapper.Map<AuthorDto>(authorsFromRepo).ShapeData(fields));
        }

Making specific formats

We can build different types of content depending on the request.

Make a DTO to hold request

Create a model to hold the DTO and add a profile to automapper

    public class AuthorFullDto
    {
        public Guid Id { get; set; }
        public string FirstName { get; set; }
        public string LastName { get; set; }
        public DateTimeOffset DateOfBirth { get; set; }
        public string MainCategory { get; set; }
    }

    public class AuthorsProfile : Profile
    {
        public AuthorsProfile()
        {
...
            CreateMap<API.Entities.Author,Models.AuthorFullDto>();

Change Controller

Apply the Produce attribute to the GetAuthor Action

        [Produces("application/json",
            "application/vnd.marvin.hateoas+json",
            "application/vnd.marvin.author.full+json",
            "application/vnd.marvin.author.full.hateoas+json",
            "application/vnd.marvin.author.friendly+json",
            "application/vnd.marvin.author.friendly.hateoas+json")]
        [HttpGet("{authorId}", Name = "GetAuthor")]
        public IActionResult GetAuthor(Guid authorId, string fields,
        [FromHeader(Name = "Accept")] string mediaType)
        {

Change the contoller to put either the full or the friendly DTO out and add links where necessary.

            // Create List to hold links
            IEnumerable<LinkDto> links = new List<LinkDto>();

            // Add links if required
            if(includeLinks) links = CreateLinkForAuthor(authorId, fields);

            // Check for full author of the media type
            var primaryMediaType = includeLinks ? 
                parsedMediaType.SubTypeWithoutSuffix.Substring(
                        0,
                        parsedMediaType.SubTypeWithoutSuffix.Length - 8) :
                parsedMediaType.SubTypeWithoutSuffix;

            // If full media type
            if(primaryMediaType == "vnd.marvin.author.full")
            {
                var fullResourceToReturn = _mapper.Map<AuthorFullDto>(authorsFromRepo)
                    .ShapeData(fields) as IDictionary<string,object>;

                if(includeLinks) fullResourceToReturn.Add("links", links);

                return Ok(fullResourceToReturn);
            }

            // Else freindly type
            var friendlyResourceToReturn = _mapper.Map<AuthorDto>(authorsFromRepo)
                    .ShapeData(fields) as IDictionary<string,object>;

            if(includeLinks) friendlyResourceToReturn.Add("links", links);

            return Ok(friendlyResourceToReturn);
        }

Adding an Input Content Type

The example provided was to add a date of death to the author. There are many ways to approach this but this approach is a concept not the way.

  • Change the Author Entity to have DOD
  • Change how we calculate Current Age
  • Create a DTO with a Date of Death
  • Change authors profile to add Date of death to GetCurrentAge and add map
  • Create a Request Header Media Type Attribute
  • In the controller copy CreateAuthor to CreateAuthorWithDateOfDeath and rename
  • Add RequestHeaderMatchesMediaType and Consumes constraint to CreateAuthors and CreateAuthorsWithDateOfDeath
RequestHeaderMatchedMediaType must be in reverse order. i.e. the default needs to be last

Change the Author Entity to have DOD

{
    public class Author
    {
...

        public DateTimeOffset? DateOfDeath { get; set; }

Change how we calculate Current Age

        public static int GetCurrentAge(
            this DateTimeOffset dataTimeOffset, 
            DateTimeOffset? dateOfDeath)
        {
            var currentDate = System.DateTime.UtcNow;

            if(dateOfDeath != null)
            {
                currentDate = dateOfDeath.Value.UtcDateTime;
            }

            var age = currentDate.Year - dataTimeOffset.Year;

            if (currentDate < dataTimeOffset.AddYears(age))
            {
                age--;
            }
            return age;
        }

Create a DTO with a Date of Death

    public class AuthorForCreationWithDateOfDeathDto : AuthorForCreationDto
    {
        DateTimeOffset? DateOfDeath {get; set;}
    }

Change authors profile to add Date of death to GetCurrentAge and add map

    public class AuthorsProfile : Profile
    {
        public AuthorsProfile()
        {
            CreateMap<API.Entities.Author, Models.AuthorDto>()
                .ForMember(
                   dest => dest.Name,
                   opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}"))
                .ForMember(
                   dest => dest.Age,
                   opt => opt.MapFrom(src => src.DateOfBirth.GetCurrentAge(src.DateOfDeath)));

            CreateMap<Models.AuthorForCreationDto, API.Entities.Author>();

            CreateMap<Models.AuthorForCreationWithDateOfDeathDto, API.Entities.Author>();
...

Create a Request Header Media Type Attribute

    [AttributeUsage(AttributeTargets.All, Inherited = true, AllowMultiple = true) ]
    public class RequestHeaderMatchedMediaTypeAttribute : Attribute, IActionConstraint
    {
        private readonly MediaTypeCollection _mediaTypes = new MediaTypeCollection();
        private readonly string _requestHeaderToMatch;

        public RequestHeaderMatchedMediaTypeAttribute(
            string requestHeaderToMatch,
            string mediaType, 
            params string[] otherMediaTypes)
        {
            _requestHeaderToMatch = requestHeaderToMatch ?? 
                throw new ArgumentNullException(requestHeaderToMatch);

            // Validate media type passed
            if(MediaTypeHeaderValue.TryParse(mediaType, out MediaTypeHeaderValue parsedMediaTypes))
            {
                _mediaTypes.Add(parsedMediaTypes);
            }
            else
            {
                throw new ArgumentException(nameof(mediaType));
            }

            foreach(var otherMediaType in otherMediaTypes)
            {
                if(MediaTypeHeaderValue.TryParse(otherMediaType, out MediaTypeHeaderValue parsedOtherMediaTypeMediaTypes))
                {
                    _mediaTypes.Add(parsedOtherMediaTypeMediaTypes);
                }
                else
                {
                    throw new ArgumentException(nameof(otherMediaTypes));
                }
            }
        }

        // Set to default
        public int Order => 0;

        public bool Accept(ActionConstraintContext context)
        {
            var requestHeaders = context.RouteContext.HttpContext.Request.Headers;
            
            // If not found do not accept
            if(!requestHeaders.ContainsKey(_requestHeaderToMatch)) return false;

            // Create Media type to compare to from the request header
            var parsedRequestMediaType = new MediaType(requestHeaders[_requestHeaderToMatch]);

            // If the media type is found the return true
            foreach(var mediaType in _mediaTypes)
            {
                var parsedMediaType = new MediaType(mediaType);

                // If found all good
                if(parsedRequestMediaType.Equals(parsedMediaType)) return true;
            }
            return false;
        }
    }

In the controller copy CreateAuthor to CreateAuthorWithDateOfDeath and rename

        [HttpPost(Name = "CreateAuthorWithDateOfDeath")]
        public ActionResult<AuthorDto> CreateAuthorWithDateOfDeath(AuthorForCreationWithDateOfDeathDto author)
        {
            var authorEntity = _mapper.Map<Author>(author);
            _courseLibraryRepository.AddAuthor(authorEntity);
            _courseLibraryRepository.Save();

            var authorToReturn = _mapper.Map<AuthorDto>(authorEntity);

            var links = CreateLinkForAuthor(authorToReturn.Id, null);

            var linkedResourcesToReturn =
                _mapper.Map<AuthorDto>(authorToReturn).ShapeData(null) as
                IDictionary<string, object>;

            linkedResourcesToReturn.Add("links", links);

            return CreatedAtRoute("GetAuthor",
                new { authorId = linkedResourcesToReturn["Id"] },
                linkedResourcesToReturn);
        }
Add RequestHeaderMatchesMediaType and Consumes constraint to CreateAuthors and CreateAuthorsWithDateOfDeath
...
        [HttpPost(Name = "CreateAuthorWithDateOfDeath")]
        [RequestHeaderMatchedMediaType("Content-Type", "application/vnd.marvin.authorforcreationwithdateofdeath+json")]
        [Consumes("application/vnd.marvin.authorforcreationwithdateofdeath+json")]
        public ActionResult<AuthorDto> CreateAuthorWithDateOfDeath(AuthorForCreationWithDateOfDeathDto author)
        {
...
        [HttpPost(Name = "CreateAuthor")]
        [RequestHeaderMatchedMediaType("Content-Type", "application/json","application/vnd.marvin.authorforcreation+json")]
        [Consumes("application/json", "application/vnd.marvin.authorforcreation+json")]
        public ActionResult<AuthorDto> CreateAuthor(AuthorForCreationDto author)
        {
...

Versioning in a RESTFul World

The answer from the author is Don't but here are popular approaches

  • Version the media types
    • application/vnd.marvice.author.friendly.v1+json
    • application/vnd.marvice.author.friendly.v2+json
  • Through the URI api/v1/authors
  • Through query string parameters api/v1/authors?api-version=v1
  • Through a custom header "api-version"=v1

Caching Resources

HTTP Cache Types, Expiration Models, Validators and Examples

Cache Types

  • Client cache, lives on the client (Private Cache)
  • Gateway/Reverse Proxy cache, lives on the server (Shared Cache)
  • Proxy Cache, lives on the network (Shared Cache)

Expiration Models

  • Private Cache (Local Storage/Mobile App)
    • Reduces bandwidth requirements
    • Less requests from cache to API
  • Shared (public) cache (Living on Server)
    • Doesn't save bandwidth between cache and API
    • Drastically lowers request to the API

Validators

Validators are used to check if the response being sent back is valid there are two types of these.

  • Strong validators
    • Changes if the body or the headers of the response change. Example of this is a ETag (Entity tag) response header. E.g. ETag: "123456"
  • Weak validators
    • Don't always change when the body or the headers of the response change. E.g. only on a significant changes. These may use ETags with a w/ ETag: "w/123456"

Examples

Examples of cache servers currently August 2020 are

Examples of CDN (content delivery network) cache servers currently August 2020 are

Directives

Cache Control Directives are on the Restful page. Restful#Cache-Control_Directives

Response Cache Attribute and Middleware

  • State for each resource where or not it's cacheable
    • Cache-Control: max-age=120
    • ResponseCache Attribute
    • This does not actually cache anything
  • Cache store (Microsoft)
    • Response caching middleware

Implementation of Cache Attribute

  • Add Cache store to application
  • Add code to specify responseCache to the request
  • Testing
  • Cache Profiles

Add Cache store to application

In Startup configure services configure cache and in Configure add useResponseCaching(). Make sure this is before routing.

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddResponseCaching();
...

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
...
            app.UseResponseCaching();

            app.UseRouting();


Add code to specify responseCache to the request

        [HttpGet("{courseId}", Name = "GetCourseForAuthor")]
        [ResponseCache(Duration = 120)]
        public ActionResult<CourseDto> GetCourseForAuthor(Guid authorId, Guid courseId)
        {
            if (_courseLibraryRepository.AuthorExists(authorId) == false) return NotFound();
...

Testing

  • Check Postman has Send no-cache header off
  • Add Breakpoint
  • Send a request

You can see this is the response header. The Cache-Control is added and the Age says how many seconds it has been active. Cache success.png

Cache Profiles

To allow configuration across controllers Microsoft provide a cache profile. You can create these and add them to your controllers, requests etc

To Create add to controllers in startup.

            services.AddControllers(setupAction =>
            {
                setupAction.ReturnHttpNotAcceptable = true;

                setupAction.CacheProfiles.Add(
                    "240SecondsCacheProfile", 
                    new CacheProfile() {
                        Duration =120
                        });
            })

Add then to the controller you wish to apply it to.

    [ApiController]
    [Route("api/authors/{authorId}/courses")]
    [ResponseCache(CacheProfileName = "240SecondsCacheProfile")]
    public class CoursesController : ControllerBase
    {
        private readonly ICourseLibraryRepository _courseLibraryRepository;

Implementation of marvin.cache.headers

Intro

This package allows the generation of things like ETag and to configure various aspects of the cache headers.

Install and Configure Default

Add package

dotnet add package marvin.cache.headers

Initialize at startup

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddHttpCacheHeaders();

            services.AddResponseCaching();
...

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
...
            app.UseResponseCaching();

            app.UseHttpCacheHeader();

            app.UseRouting();

Remove previous implementation in the controller. I.E. look for ResponseCache. This will now generate ETags out of the box.

Expiration Model Options example

            services.AddHttpCacheHeaders((expirationModelOptions) => {
                expirationModelOptions.MaxAge = 60;
                expirationModelOptions.CacheLocation = Marvin.Cache.Headers.CacheLocation.Private;
           });

Validation Model Options example

            services.AddHttpCacheHeaders((expirationModelOptions) => {
...
            },
            (validationModelOptions) => {
                validationModelOptions.MustRevalidate = true;
            });

Usage control

Like the Microsoft, these can be applied at Controller and request level. E.g.

...
        [HttpGet("{courseId}", Name = "GetCourseForAuthor")]
        [HttpCacheExpiration(CacheLocation = CacheLocation.Public, MaxAge = 1000)]
        [HttpCacheValidation(MustRevalidate = false)]
        //[ResponseCache(Duration = 120)]
        public ActionResult<CourseDto> GetCourseForAuthor(Guid authorId, Guid courseId)
        {
...