This library pretends to expose some models and utilities useful to create an Hypermedia API using ASP.NET 5 Framework.
The idea is not to create a framework, only a set of utilities that can be used together or not.
Example:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.OutputFormatters.Clear();
options.OutputFormatters.Add(new HypermediaApiJsonOutputFormatter());
options.InputFormatters.Clear();
options.InputFormatters.Add(new HypermediaApiJsonInputFormatter());
options.Filters.Add(new PayloadValidationFilter());
options.Filters.Add(new RequiredPayloadFilter());
// Insert at the beginning to ensure that this model binder always capture
// ICustomRepresentationModel bindings.
options.ModelBinders.Insert(0, new CustomRepresentationModelBinder());
});
services.AddApiMappers();
services.AddSuitableValidators();
services.AddLogging();
services.AddLinkHelper<CustomLinkHelper>();
}
public void Configure(IApplicationBuilder app, ILoggerFactory loggerfactory)
{
app.UseApiErrorHandler();
app.UseMvc();
app.UseNotFoundHandler();
loggerfactory.AddProvider(new CustomLoggerProvider());
}
}
-
Relations
A Relation represents a behavior in the system, see Hypermedia and H Factors. Each link has a
rel
property with one or more relations separated by spaces.A relation defines how a link works. There are standard relations, for example
self
,next
, etc. And custom relations defined by implementer and are expressed as URLs to HTML documents with the behavior details. In general, custom relations are Action Relations. -
Action Relations
Controller actions are decorated with
ActionRelation
attributes that indicate: HTTP method, Input model, Output model, a description and a link to related HTML document.These attributes also allow to define the MVC route, and some other metadata:
NotImplemented
- The action is not implemented yet.IsExperimental
- The behavior is not confirmed and could change in the near future.Description
- Default description of links to this action.SuitableValidators
- See related section in under MVC Filters.
example:
public class SubscribersController : Controller { // . . . [ImportSubscribersRelation( Template = "/accounts/{accountName}/lists/{listId}/subscribers/import", SuitableValidators = new[] { typeof(ImportSubscribersSuitableValidator) })] public async Task<CreationResult> ImportSubscribersToList(string accountName, int listId, [FromBody] [NotNull] ImportSubscribersPayload subscribers) { // . . .
-
Models
All API input and output types should be represented as Model classes.
The convention for these classes is to have camel case properties and an special property called
_links
where controllers will load related links based on the state of the application.See API Mappers section for information about a provided utility to map domain objects to API Models.
-
Problems
Problems are an special kind of models used to represent errors. ApiErrorHandler Middleware is in charge of render them.
Example:
if (Exists(list.name)) { throw new ApiException(new DuplicatedListNameProblem(list.name)); }
Making Sense Hypermedia API provides a LinkHelper
that allows to use strongly typed code to generate links to actions, including metadata information and processing Suitable Validators (See related section in under MVC Filters).
Example:
public class SubscribersController : Controller
{
// . . .
[GetSubscriberCollectionRelation(Template = "/accounts/{accountName}/lists/{listId}/subscribers", Description = "Get subscribers of a list")]
public async Task<SubscriberCollectionPage> GetSubscribersOfList(string accountName, int listId, int? page = null, int? per_page = null)
{
var pagination = new PaginationParameters(page, per_page);
SubscriberCollectionPage result = await _service.GetSubscribersOfList(accountName, userId, listId);
return result
// Adds links to output model (a page of subscribers)
.AddLinks(
_link.ToHomeAccount(),
_link.ToAction<ListsController>(x => x.GetList(accountName, listId)),
_link.ToAction<SubscribersController>(x => x.ImportSubscribersToList(accountName, listId, null)),
_link.ToAction<SubscribersController>(x => x.SubscribeToList(accountName, listId, null)),
_link.ToAction<SubscribersController>(c => c.Unsubscribe(accountName, null)))
// Adds page links to output model (first, prev, next, etc)
.AddPageLinks(
(p, pp) => _link.ToAction<SubscribersController>(x => x.GetSubscribersOfList(accountName, listId, p, pp)))
// Adds links to each child item (each subscriber in the page)
.AddItemsLinks(item => new[]
{
_link.ToAction<SubscribersController>(c => c.GetSubscriber(accountName, item.emailMd5)),
_link.ToAction<SubscribersController>(c => c.EditSubscriber(accountName, item.emailMd5, null))
})
.Cast<SubscriberCollectionPage>();
}
// . . .
This middleware render all exceptions as Problem Details (See Problem Details for HTTP APIs)
It catches:
Response.StatusCode == 401 (Unauthorized)
and render anMakingSense.AspNetCore.HypermediaApi.Problems.UnauthorizedProblem
Response.StatusCode == 403 (Forbidden)
and render aMakingSense.AspNetCore.HypermediaApi.Problems.ForbiddenProblem
Response.StatusCode == 404 (Not Found)
and render aMakingSense.AspNetCore.HypermediaApi.Problems.RouteNotFoundProblem
- Any
MakingSense.AspNetCore.Authentication.Abstractions.AuthenticationException
and render them asMakingSense.AspNetCore.HypermediaApi.Problems.AuthenticationErrorProblem
- Any
MakingSense.AspNetCore.HypermediaApi.ExceptionHandling.ApiException
and render the inner problem (Problem
property) - Any unhandled exception and render them as
MakingSense.AspNetCore.HypermediaApi.Problems.UnexpectedProblem
Basic Configuration example:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
// If you want to automatically render links to self and home resources,
// it is required to register a LinkHelper.
services.AddScoped<ILinkHelper, CustomLinkHelper>();
}
public void Configure(IApplicationBuilder app, ILoggerFactory loggerfactory)
{
app.UseApiErrorHandler();
app.UseMvc();
}
}
It is a terminal middleware, if a request comes to here it throws an ApiException
with a RouteNotFoundProblem
.
Suitable validators are used to validate if given a set of parameters it is possible to execute an action depending on the state of the system.
They are useful to implement HATEOAS because are invoked during link generation and also before execute a task (after parameters validation).
Initialization:
public void ConfigureServices(IServiceCollection services)
{
// . . .
services.AddSuitableValidators(typeof(ACustomSuitableValidatorClass).GetAssembly);
// . . .
Assigning to a controller action:
public class ListsController
{
// . . .
[CreateListRelation(
Template = "/accounts/{accountName}/lists",
SuitableValidators = new[] { typeof(CreateListSuitableValidator) })]
public async Task<CreationResult> CreateList(string accountName, [FromBody] ListModel list)
{
// . . .
Example implementation for CreateList
method (previous example):
public class CreateListSuitableValidator : ISuitableValidator
{
readonly ICustomService _customService;
public static readonly string arg_accountName = "accountName";
public CreateListSuitableValidator([NotNull] customService)
{
_customService = customService;
}
public async Task<Problem> ValidateAsync(IDictionary<string, TemplateParameter> values)
{
if (values[arg_accountName].ForceValue)
{
if (await _customService.MaxListsReached((string)values[arg_accountName].ForcedValue))
{
return new MaximumNumberOfListsReachedProblem();
}
}
return null;
}
}
}
Validation Filters are standard MVC filters with common behavior that can be reused in your Hypermedia API project. They are executed after standard MVC validation.
Setup:
public void ConfigureServices(IServiceCollection services)
{
// . . .
services.AddMvc(options => {
options.Filters.Add(new PayloadValidationFilter());
options.Filters.Add(new RequiredPayloadFilter());
});
// . . .
If there was validation errors (!ModelState.IsValid
), it generates and throws the ValidationProblem
with information about validation errors.
It checks if expected complex types are included in the request, based in the follow criteria:
-
NotNull Attributes
RequiredPayloadFilter verifies if parameters marked as
[NotNull]
are effectively not null. If not, it throws aValidationProblem
.public async Task<CreationResult> CreateList(string accountName, [NotNull] [FromBody] ListModel list)
-
Relation Attribute Input
RequiredPayloadFilter verifies if the argument associated to relation input parameter has value.
public class CreateListRelation : ActionRelationAttribute { public override HttpMethod? Method => HttpMethod.POST; public override Type InputModel => typeof(ListModel); // . . .
[CreateListRelation(Template = "/accounts/{accountName}/lists")] public async Task<CreationResult> CreateList(string accountName, [FromBody] ListModel list)
HypermediaApiJsonInputFormatter ignores request content-type
header assuming that the content type is application/json
.
HypermediaApiJsonOutputFormatter renders output JSON content indented.
CustomRepresentationModelBinder
handles binding of objects that implement ICustomRepresentationModel
interface.
CustomRepresentationModel
and ICustomRepresentationModel
allow parse request content and generate response content based on custom logic.
It is specially useful for resources with only a representation, for example images or other files.
Example:
public class Startup
{
// . . .
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
// . . .
// Insert at the beginning to ensure that this model binder always capture
// ICustomRepresentationModel bindings.
options.ModelBinders.Insert(0, new CustomRepresentationModelBinder());
});
// . . .
}
}
[NoSchema]
public class CampaignContent : CustomRepresentationModel, IValidatableObject
{
public override string ContentType => "text/html";
public string ContentAsText { get; set; }
public override async Task SetContentAsync(Stream stream)
{
using (var reader = new StreamReader(stream))
{
ContentAsText = await reader.ReadToEndAsync();
}
}
protected override Task WriteContentAsync(HttpResponse response) => response.WriteAsync(ContentAsText);
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrEmpty(ContentAsText))
{
yield return new ValidationResult("CampaignContent cannot be empty.");
}
}
}
public class CampaignsController : Controller
{
// . . .
[EditCampaignContentRelation(Template = "/campaigns/{campaignId}/content")]
public async Task<MessageModel> EditCampaignContent(int campaignId, CampaignContent content)
{
// Accepts PUT request with a HTML content in body
await _campaignsApiService.EditContent(campaignId, content.ContentAsText);
}
[GetCampaignContentRelation(Template = "/campaigns/{campaignId}/content")]
public async Task<CampaignContent> GetCampaignContent(int campaignId)
{
// Returns a HTML content in body, including `text/html` content type and link headers.
var contentAsText = await _campaignsApiService.GetContent(campaignId);
var result = new CampaignContent();
result.ContentAsText = contentAsText;
result.AddLinks(
_link.ToAction<CampaignsController>(x => x.GetCampaignContent(campaignId)).SetSelf(),
_link.ToAction<CampaignsController>(x => x.GetCampaign(campaignId)).AddRel<ParentRelation>(),
_link.ToAction<CampaignsController>(x => x.EditCampaignContent(campaignId, null)),
_link.ToHomeAccount());
return result;
}
ApiMapper<,>
Provides a convenient interface and utilities to implement mappers to and from API models with facilities to map pages and collections.
To register all API Mappers in an assembly is enough with the follow line:
public void ConfigureServices(IServiceCollection services)
{
// . . .
services.AddApiMappers(typeof(ACustomMapperClass).GetAssembly);
// . . .
Then, the mappers could be injected using the interface:
public class ListsController : Controller
{
private readonly ILogger _logger;
private readonly IApiMapper<InternalClass, ApiOutputClass> _internalToOutputMapper;
public ListsController(
[NotNull] ILogger<ListsController> logger,
[NotNull] IApiMapper<InternalClass, ApiOutputClass> internalToOutputMapper)
{
_logger = logger;
_internalToOutputMapper = internalToOutputMapper;
}
// . . .