Dotnet api linux: Difference between revisions
Line 1,787: | Line 1,787: | ||
* Create a DTO with a Date of Death | * Create a DTO with a Date of Death | ||
* Change authors profile to add Date of death to GetCurrentAge and add map | * 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 | * In the controller copy CreateAuthor to CreateAuthorWithDateOfDeath and rename | ||
* Add RequestHeaderMatchesMediaType and Consumes constraint to CreateAuthors and | * Add RequestHeaderMatchesMediaType and Consumes constraint to CreateAuthors and | ||
Line 1,848: | Line 1,849: | ||
... | ... | ||
</syntaxhighlight> | </syntaxhighlight> | ||
====Create a Request Header Media Type Attribute==== | |||
<syntaxhighlight lang="c#"> | <syntaxhighlight lang="c#"> | ||
[AttributeUsage(AttributeTargets.All, Inherited = true, AllowMultiple = true) ] | [AttributeUsage(AttributeTargets.All, Inherited = true, AllowMultiple = true) ] |
Revision as of 07:53, 5 August 2020
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
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.
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.
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
- HAL (Hypertext Application Language)
- https://bit.ly/2YAyrUc
- Provides a set of conventions for expressing hyperlinks in either Json or XML
- Siren (Structured Interface for Representing Entities)
- https://github.com/kevinswiber/siren
- Link format and descriptions of what to send to those links
- Json-LD
- http://json-ld.org/
- Lightweight linked data format
- Json-API
- https://jsonapi.org/
- Specification for building JSON APIs
- OData
- http://www.odata.org/
- Effort to standardize REST APIs
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.
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
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)
{
...