- Creating Project: Scaffolding:
- New Project -> .NET Core -> ASP.NET Core Web Application
- Then, API template
- Use NuGet to check for any package updates
In launchSettings.json
, set launchBrowser
to false
In Startup.cs
:
In ConfigureServices
, turn off Pascal-case routing names"
services.AddRouting(opt => opt.LowercaseUrls = true);
Delete default controller and add RootController.cs
- Allow RootController to inherit from
: Controller
namespace. (and import) - Set up first route. Routes return IActionResult which can return HTTP status codes, json payloads, or both.
- For now, make the Root return a reference to itself
Url.Link
method returns Urls for controller methods
[Route("/")]
public class RootController : Controller
{
[HttpGet(Name = nameof(GetRoot))]
public IActionResult GetRoot()
{
var response = new { href = Url.Link(nameof(GetRoot), null) };
return Ok(response);
}
}
Media Type Versioning:
- Install Microsoft.AspNetCoreMvc.Versioning from NuGet
- In Startup#ConfigureServices:
services.AddApiVersioning(opt =>
{
opt.ApiVersionReader = new MediaTypeApiVersionReader();
opt.AssumeDefaultVersionWhenUnspecified = true;
opt.ReportApiVersions = true;
opt.DefaultApiVersion = new ApiVersion(1, 0);
opt.ApiVersionSelector = new CurrentImplementationApiVersionSelector(opt);
});
In RouteController.cs, add Api Version attribute to route:
[Route("/")]
[ApiVersion("1.0")]
public class RootController : Controller // ... //
Now, specific controllers or methods can be versioned, otherwise everything will be v1.0
Add new route. [Route("[controller]")]
automatically includes name of current controller
[Route("/[controller]")] // controller template will automatically match name of controller ("/Rooms")
public class RoomsController : Controller
{
[HttpGet(Name = nameof(GetRooms))]
public IActionResult GetRooms()
{
throw new NotImplementedException();
}
}
Add route to Routes Controller as new object:
public IActionResult GetRoot()
{
var response = new {
href = Url.Link(nameof(GetRoot), null),
rooms = new { href = Url.Link(nameof(RoomsController.GetRooms), null) }
};
return Ok(response);
}
Create models and ApiError model: Models\ApiError.cs
Add this model:
public class ApiError
{
public string Message { get; set; }
public string Detail { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore)]
[DefaultValue("")]
public string StackTrace { get; set; }
}
Create filters for filter interfaces: Filters\JsonExceptionFilter.cs
Add this filter:
public class JsonExceptionFilter : IExceptionFilter
{
private readonly IHostingEnvironment _env;
public JsonExceptionFilter(IHostingEnvironment env)
{
_env = env;
}
public void OnException(ExceptionContext context)
{
var error = new ApiError();
if (_env.IsDevelopment())
{
error.Message = context.Exception.Message;
error.Detail = context.Exception.StackTrace;
}
else
{
error.Message = "An error has occured...";
error.Detail = context.Exception.Message;
}
context.Result = new ObjectResult(error)
{
StatusCode = 500
};
}
}
Add filter at runtime. In Startup.cs #ConfigureServices, services.AddMvc(opt => {/.../})
:
opt.Filters.Add(typeof(JsonExceptionFilter));
- Enable HTTPS (SSL Support)
Click Solution -> Properties -> Debug Check Enable SSL
Enforce HTTPS:
(You could add [RequireHttps]
to every controller)
To add to the entire API:
In Startup.cs
=> #ConfigureServices
=> Services.AddMvc(opt => /.../
)
opt.Filters.Add(typeof(RequireHttpsAttribute));
For development, add way to capture randomly-generated SSL port:
At top of Startup class:
private readonly int? _httpsPort;
Modify constructor:
public Startup(IConfiguration configuration, IHostingEnvironment env)
{
Configuration = configuration;
if (env.IsDevelopment())
{
var launchJsonConfig = new ConfigurationBuilder()
.SetBasePath(env.ContentRootPath)
.AddJsonFile("Properties\\launchSettings.json")
.Build();
_httpsPort = launchJsonConfig.GetValue<int>("iisSettings:iisExpress:sslPort");
}
}
Add SSL port to AddMvc options:
opt.SslPort = _httpsPort;
- Addt'l security headers
In NuGet, install: NWebSec.AspNetCore.Middleware
.
In Startup.cs#Configure
:
app.UseHsts(opt => {
opt.MaxAge(days: 180);
opt.IncludeSubdomains();
opt.Preload();
});
This adds Strict-Transport-Security layer for browsers that respect this.
In Models, create Resource.cs
.
public abstract class Resource
{
[JsonProperty(Order = -2)]
public string Href { get; set; }
}
In Models, create new file(HotelInfo.cs
) and it should inherit rfom Resource:
public class HotelInfo : Resource /.../
Add Attributes:
public class HotelInfo : Resource
{
public string Title { get; set; }
public string Tagline { get; set; }
public string Emaeil{ get; set; }
public string Website{ get; set; }
public Address Location { get; set; }
}
public class Address
{
public string Street{ get; set; }
public string City { get; set; }
public string Country{ get; set; }
}
Add data to appSettings.json
.
{
"Logging": {
"IncludeScopes": false,
"Debug": {
"LogLevel": {
"Default": "Warning"
}
},
"Console": {
"LogLevel": {
"Default": "Warning"
}
},
"Info": {
"title": "The Landon Hotel West End London",
"tagline": "You'll feel at home in our neighborhood",
"location": {
"street": "123 Oxford Street",
"city": "London W1S 2YF",
"country": "England"
},
"email": "[email protected]",
"website": "www.landonhotel.com"
}
}
}
Bring Info:
node into Startup#ConfigureServices:
services.Configure<HotelInfo>(Configuration.GetSection("Info"));
Create constructor which imports hotel data via IOptions:
private readonly HotelInfo _hotelInfo;
public InfoController(IOptions<HotelInfo> hotelInfoAccessor)
{
_hotelInfo = hotelInfoAccessor.Value;
}
Create Route:
[HttpGet(Name = nameof(GetInfo))]
public IActionResult GetInfo()
{
_hotelInfo.Href = Url.Link(nameof(GetInfo), null);
return Ok(_hotelInfo);
}
Add route to RootController response:
hotelInfo = new { href = Url.Link(nameof(InfoController.GetInfo), null) }
-
Install Microsoft.EntityFrameworkCore and Microsoft.EntityFrameworkCore.InMemory (NuGet)
-
Add a DBContext class (this one's at root of the project). It will inherit from DbContext class
public class HotelApiContext : DbContext
Create constructor: (RoomEntity class has not been created yet:
public HotelApiContext(DbContextOptions options)
: base(options) { }
public DbSet<RoomEntity> Rooms { get; set; }
In Startup.cs
, add EFC using: using Microsoft.EntityFrameworkCore;
.
InStartup::ConfigureServices:
services.AddDbContext<HotelApiContext>(opt => opt.UseInMemoryDatabase());
To models add: RoomEntity.cs
Create Entity
public class RoomEntity
{
public Guid Id { get; set; }
public string Name{ get; set; }
public int Rate { get; set; }
}
Create Model: Rooms.cs
which inherits Resource.
Note: Creating Entity and Model separately helps control what data gets returned in the API.
In Startup::ConfigureServices, in the env.IsDevelopment() block:
using (var serviceScope = app.ApplicationServices.CreateScope())
{
var context = serviceScope.ServiceProvider.GetService<HotelApiContext>();
AddTestData(context);
}
Notes: Scoping error workaround came from here: https://stackoverflow.com/questions/46063945/cannot-resolve-dbcontext-in-asp-net-core-2-0/46064116.
Create private AddTestData method:
private static void AddTestData(HotelApiContext context)
{
context.Rooms.Add(new RoomEntity
{
Id = Guid.Parse("301df04d-8679-4b1b-ab92-0a586ae53d08"),
Name = "Oxford Suite",
Rate = 10119,
});
context.Rooms.Add(new RoomEntity
{
Id = Guid.Parse("ee2b83be-91db-4de5-8122-35a9e9195976"),
Name = "Driscoll Suite",
Rate = 23959
});
context.SaveChanges();
}
In RoomsController, create constructor to inject Rooms DbContext:
private readonly HotelApiContext _context;
protected RoomsController(HotelApiContext context)
{
_context = context;
}
For returning single item, create async function using Task:
// /rooms/{roomId}
[HttpGet("{roomId}", Name = nameof(GetRoomByIdAsync))]
public async Task<IActionResult> GetRoomByIdAsync(Guid roomId, CancellationToken ct)
{
var entity = await _context.Rooms.SingleOrDefaultAsync(r => r.Id == roomId, ct);
if (entity == null) return NotFound();
var resource = new Room
{
Href = Url.Link(nameof(GetRoomByIdAsync), new { roomId = entity.Id }),
Name = entity.Name,
Rate = entity.Rate / 100.0m
};
return Ok(resource);
}
This is done so that controller is not interacting with Data Context directly, whcih separates concerns and makes controller easier to test with mocks.
Create services folder, create interface: Services\IRoomService.cs
Create this interface:
public interface IRoomService
{
Task<Room> GetRoomAsync(Guid id, CancellationToken ct);
}
Create class to implement IRoomService: Services/DefaultRoomService.cs
(Default name makes it distinguishable from a mock service)
DefaultService will implement the interface. ApiContext will be injected. Copy data access blocks from controller.
NotFound()
gets changed to return null
since it's part of controller class.
public async Task<Room> GetRoomAsync(Guid id, CancellationToken ct)
{
// the call returns the entity
var entity = await _context.Rooms.SingleOrDefaultAsync(r => r.Id == id, ct);
if (entity == null) return null;
// the entity properties are extracted into Room resource which is returned to the user
var resource = new Room
{
Href = null, // Url.Link(nameof(GetRoomByIdAsync), new { roomId = entity.Id }),
Name = entity.Name,
Rate = entity.Rate / 100.0m
};
return resource;
}
Back to controller, change constructor to point to IRoomsService:
private readonly IRoomService _roomContext;
public RoomsController(IRoomService context)
{
_roomContext = context;
}
Change task to implement service:
// /rooms/{roomId}
[HttpGet("{roomId}", Name = nameof(GetRoomByIdAsync))]
public async Task<IActionResult> GetRoomByIdAsync(Guid roomId, CancellationToken ct)
{
var resource = await _roomContext.GetRoomAsync(roomId, ct);
if (resource == null) return NotFound();
return Ok(resource);
}
Finally, modify configuration services in Startup so that a DefaultRoomService is called every tim IRoomService is called (no singletons).
In Startup:ConfigureServices
:
services.AddScoped<IRoomService, DefaultRoomService>();
In NuGet, install AutoMapper
Create mapping profile class in Infrastructure: Infrastructure/MappingProfile.cs
Build out this class:
public class MappingProfile : Profile
{
protected MappingProfile()
{
CreateMap<RoomEntity, Room>()
.ForMember(dest => dest.Rate, opt => opt.MapFrom(src => src.Rate / 100.0m));
}
}
From NuGet install AutoMapper.Extensions.Microsoft.DependencyInjection
Add AutoMapper in `Startup:ConfigureServices
services.AddAutoMapper(); // also, import AutoMapper using AutoMapper;
Refactor DefaultRoomService as:
public class DefaultRoomService : IRoomService
{
private readonly HotelApiContext _context;
public DefaultRoomService(HotelApiContext hotelApiContext)
{
_context = hotelApiContext;
}
public async Task<Room> GetRoomAsync(Guid id, CancellationToken ct)
{
// the call returns the entity
var entity = await _context.Rooms.SingleOrDefaultAsync(r => r.Id == id, ct);
if (entity == null) return null;
return Mapper.Map<Room>(entity);
}
}
ION specifications have three definitions: href, method, and rel (relation):
{
"href" : "https://example.com",
"method" : "GET",
"rel" : [ "collection" ]
}
Create a Link Model
public class Link
{
public const string GET = "GET";
public static Link To(string routeName, object routeValues = null)
=> new Link
{
RouteName = routeName,
RouteValues = routeValues,
Method = GET,
Relations = null
};
[JsonProperty(Order = -4)] // puts this at top of body
public string Href { get; set; }
[JsonProperty(Order = -3, NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore)]
[DefaultValue(GET)] // sets GET const as default value
public string Method { get; set; }
[JsonProperty(Order = -2, PropertyName = "rel", NullValueHandling = NullValueHandling.Ignore)] // PropertyName returns "rel" instead of "Relations"
public string[] Relations { get; set; }
[JsonIgnore] // these will store route name and route values, JsonIgnore excludes them from Json response body
public string RouteName { get; set; }
[JsonIgnore]
public object RouteValues { get; set; }
}
Refactor RootController:GetRoot to return RootResponse:
[HttpGet(Name = nameof(GetRoot))]
public IActionResult GetRoot()
{
var response = new RootResponse {
Href = null,
rooms = Link.To(nameof(RoomsController.GetRooms)),
Info = Link.To(nameof(InfoController.GetInfo))
};
return Ok(response);
}
Create RootResponse model:
public class RootResponse : Resource
{
public Link Info { get; set; }
public Link rooms { get; set; }
}
Rewrite Links with a Filter:
Create Infrastructure\LinkReWriter.cs
Add:
public class LinkRewriter
{
private readonly IUrlHelper _urlHelper;
public LinkRewriter(IUrlHelper urlHelper) // Bring in IUrlHelper from MVC
{
_urlHelper = urlHelper;
}
public Link Rewrite(Link original)
{
if (original == null) return null; // Sanity check for null values
return new Link
{
Href = _urlHelper.Link(original.RouteName, original.RouteValues), // Link Href is created here
Method = original.Method, // everything else is just passed on
Relations = original.Relations
};
}
}
Create new filter: Filters\LinkRewritingFilter.cs
(I have no idea what this does :( )
public class LinkRewritingFilter : IAsyncResultFilter
{
private readonly IUrlHelperFactory _urlHelperFactory;
public LinkRewritingFilter(IUrlHelperFactory urlHelper)
{
_urlHelperFactory = urlHelper;
}
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
var asObjectResult = context.Result as ObjectResult;
bool shouldSkip = asObjectResult?.Value == null || asObjectResult?.StatusCode != (int)HttpStatusCode.OK;
if (shouldSkip)
{
await next();
return;
}
var rewriter = new LinkRewriter(_urlHelperFactory.GetUrlHelper(context));
RewriteAllLinks(asObjectResult.Value, rewriter);
}
private static void RewriteAllLinks(object model, LinkRewriter rewriter)
{
if (model == null) return;
var allProperties = model.GetType().GetTypeInfo().GetAllProperties().Where(prop => prop.CanRead).ToArray();
var linkProperties = allProperties
.Where(prop => prop.CanWrite && prop.PropertyType == typeof(Link));
foreach (var linkProperty in linkProperties)
{
var rewritten = rewriter.Rewrite(linkProperty.GetValue(model) as Link);
if (rewritten == null) continue;
linkProperty.SetValue(model, rewritten);
}
var arrayProperties = allProperties.Where(prop => prop.PropertyType.IsArray);
RewriteLinksInArrays(arrayProperties, model, rewriter);
var objectProperties = allProperties.Except(linkProperties).Except(arrayProperties);
RewriteLinksInNestedObjects(objectProperties, model, rewriter);
}
private static void RewriteLinksInNestedObjects(
IEnumerable<PropertyInfo> objectProperties,
object obj,
LinkRewriter rewriter
)
{
foreach (var objectProperty in objectProperties)
{
if (objectProperty.PropertyType == typeof(string))
{
continue;
}
var typeInfo = objectProperty.PropertyType.GetTypeInfo();
if (typeInfo.IsClass)
{
RewriteAllLinks(objectProperty.GetValue(obj), rewriter);
}
}
}
private static void RewriteLinksInArrays(
IEnumerable<PropertyInfo> arrayProperties,
object obj,
LinkRewriter rewriter
)
{
foreach (var arrayProperty in arrayProperties)
{
var array = arrayProperty.GetValue(obj) as Array ?? new Array[0];
foreach (var element in array)
{
RewriteAllLinks(element, rewriter);
}
}
}
}
Add filter in Startup:ConfigurationServices:
services.AddMcv(opt => opt.Filters.Add(typeof(LinkRewritingFilter)));
Extending Filter for Resource URL as well:
In Resource.cs
model, change href
string to public Link Self { get; set; }
In RootController
, change 'Href' property in response
to: Self = Link.To(nameof(GetRoot))
.
In MappingProfile
, add the following to MappingProfile()
:
.ForMember(dest => dest.Self, opt => opt.MapFrom(src =>
Link.To(nameof(Controllers.RoomsController.GetRoomByIdAsync), new { roomId = src.Id })));
Update Resource.cs
model to inherit Link: public abstract class Resource : Link
.
then, Add [JsonIgnore]
attribute over Self property
in LinkRewritingfilters:RewriteAllLinks
, add special check to handle hidden Self property (at the end of the `foreach loop):
if (linkProperty.Name == nameof(Resource.Self))
{
allProperties.SingleOrDefault(p => p.Name == nameof(Resource.Href))
?.SetValue(model, rewritten.Href);
allProperties.SingleOrDefault(p => p.Name == nameof(Resource.Method))
?.SetValue(model, rewritten.Method);
allProperties.SingleOrDefault(p => p.Name == nameof(Resource.Relations))
?.SetValue(model, rewritten.Relations);
}