Profiles in NetCore
Background
In the original Ed-Fi ODS Profiles was implemented by creating a custom Controller Selector. This was to enable controller swapping for a specific route. So in the case where you have a profile and wanted to get all resources from Schools, the controller selector would choose a custom controller specific for the profile. The route in this case would not change and would be data/v3/ed-fi/schools.
Problem
How do we utilize profiles in net core and reduce the amount of rework?
Analysis
ConsumesAttribute
Net core has the concept of Consumes. It is an attribute that allows for the specifying the media type you want a post or put action to go to. This works well when you have multiple actions on a controller for example we can consume a form post and/or json:Â
namespace EdFi.Ods.Api.Controllers { [ApiExplorerSettings(IgnoreApi = true)] [ApiController] [Route("oauth/token")] [Produces("application/json")] [AllowAnonymous] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public class TokenController : ControllerBase { private readonly ILog _logger = LogManager.GetLogger(typeof(TokenController)); private readonly ITokenRequestProvider _requestProvider; public TokenController(ITokenRequestProvider provider) { _requestProvider = provider; } [HttpPost] [AllowAnonymous] [Consumes("application/json")] public async Task<IActionResult> Post([FromBody] TokenRequest tokenRequest) { // Handle token request var authenticationResult = await _requestProvider.HandleAsync(tokenRequest); if (authenticationResult.TokenError != null) { return BadRequest(authenticationResult.TokenError); } return Ok(authenticationResult.TokenResponse); } [HttpPost] [AllowAnonymous] [Consumes("application/x-www-form-urlencoded")] public async Task<IActionResult> PostFromForm([FromForm] TokenRequest tokenRequest) { // Look for the authorization header, since we MUST support this method of authorization // https://tools.ietf.org/html/rfc6749#section-2.3.1 // Decode and parse the client id/secret from the header // Authorization is in a form of Bearer <encoded client and secret> if (!Request.Headers.ContainsKey("Authorization")) { _logger.Debug($"Header is missing authorization credentials"); return Unauthorized(); } string[] encodedClientAndSecret = Request.Headers["Authorization"] .ToString() .Split(' '); if (encodedClientAndSecret.Length != 2) { _logger.Debug("Header is not in the form of Basic <encoded credentials>"); return Unauthorized(); } if (!encodedClientAndSecret[0] .EqualsIgnoreCase("Basic")) { _logger.Debug("Authorization scheme is not Basic"); return Unauthorized(new TokenError(TokenErrorType.InvalidClient)); } string[] clientIdAndSecret; try { clientIdAndSecret = Encoding.UTF8.GetString(Convert.FromBase64String(encodedClientAndSecret[1])) .Split(':'); } catch (Exception ) { return BadRequest(new TokenError(TokenErrorType.InvalidRequest)); } // Correct format will include 2 entries // format of the string is <client_id>:<client_secret> if (clientIdAndSecret.Length == 2) { tokenRequest.Client_id = clientIdAndSecret[0]; tokenRequest.Client_secret = clientIdAndSecret[1]; } var authenticationResult = await _requestProvider.HandleAsync(tokenRequest); if (authenticationResult.TokenError != null) { return BadRequest(authenticationResult.TokenError); } return Ok(authenticationResult.TokenResponse); } } }
This works well when you are using the same controller. However, in our case this does not work since we have two different controllers, so the router does not choose the correct controller when there are multiple controllers with the same route.Â
IActionConstraint
The net core team has created this interface that allows us to make a determination on an action. (IActionConstraint). This allows for a determination on what action to take and for our use case this seems to work well when it added as an attribute on the controller.
Recommendation
Update ProfileContentAttribute and implement IActionConstraint.Â
public class ProfileContentTypeAttribute : Attribute, IActionConstraint { private readonly ILog _logger = LogManager.GetLogger(typeof(ProfileContentTypeAttribute)); public int Order { get; set; } = 0; public string MediaTypeName { get; } public ProfileContentTypeAttribute(string mediaTypeName) { MediaTypeName = mediaTypeName; } public bool Accept(ActionConstraintContext context) { var requestHeaders = context.RouteContext.HttpContext.Request.GetTypedHeaders(); // Route to the controller for get requests bool isReadable = requestHeaders.Accept != null && requestHeaders.Accept .Where(x => x.MediaType.HasValue) .Select(x => x.MediaType.Value) .Any(x => x.Contains(MediaTypeName, StringComparison.InvariantCultureIgnoreCase)); // Ideally we want to use the consumes attribute, however, this does not work in this use case because we do not // augment the original controller. Instead we will just route to the controller for put and post requests. bool isWritable = requestHeaders.ContentType?.MediaType != null && requestHeaders.ContentType.MediaType.HasValue && requestHeaders.ContentType.MediaType.Value .Contains(MediaTypeName, StringComparison.InvariantCultureIgnoreCase); if (isReadable || isWritable) { _logger.Debug($"Profile is being applied to request {context.RouteContext.HttpContext.Request.GetDisplayUrl()}"); } return isReadable || isWritable; }
When the profile controller will be chosen when the Accept method is true, otherwise the default controller on the route will be used.Â
Next update the code gen to update the controllers for profiles by adding the ProfileContentType attribute to the controller declaration for example:
namespace EdFi.Ods.Api.Services.Controllers.Schools.EdFi.Test_Profile_Resource_ExcludeOnly { [ApiExplorerSettings(IgnoreApi = true)] [ExcludeFromCodeCoverage] [ApiController] [Authorize] [ProfileContentType("application/vnd.ed-fi.school.test-profile-resource-excludeonly")] [Route("ed-fi/schools")] public partial class SchoolsController : DataManagementControllerBase< Api.Common.Models.Resources.School.EdFi.Test_Profile_Resource_ExcludeOnly_Readable.School, Api.Common.Models.Resources.School.EdFi.Test_Profile_Resource_ExcludeOnly_Writable.School, Entities.Common.EdFi.ISchool, Entities.NHibernate.SchoolAggregate.EdFi.School, Api.Common.Models.Requests.Schools.EdFi.Test_Profile_Resource_ExcludeOnly.SchoolPut, Api.Common.Models.Requests.Schools.EdFi.Test_Profile_Resource_ExcludeOnly.SchoolPost, Api.Common.Models.Requests.Schools.EdFi.Test_Profile_Resource_ExcludeOnly.SchoolDelete, Api.Common.Models.Requests.Schools.EdFi.Test_Profile_Resource_ExcludeOnly.SchoolGetByExample> { public SchoolsController(IPipelineFactory pipelineFactory, ISchoolYearContextProvider schoolYearContextProvider, IRESTErrorProvider restErrorProvider, IDefaultPageSizeLimitProvider defaultPageSizeLimitProvider) : base(pipelineFactory, schoolYearContextProvider, restErrorProvider, defaultPageSizeLimitProvider) { } protected override void MapAll(Api.Common.Models.Requests.Schools.EdFi.Test_Profile_Resource_ExcludeOnly.SchoolGetByExample request, Entities.Common.EdFi.ISchool specification) { // Copy all existing values specification.SuspendReferenceAssignmentCheck(); specification.AdministrativeFundingControlDescriptor = request.AdministrativeFundingControlDescriptor; specification.CharterApprovalAgencyTypeDescriptor = request.CharterApprovalAgencyTypeDescriptor; specification.CharterApprovalSchoolYear = request.CharterApprovalSchoolYear; specification.CharterStatusDescriptor = request.CharterStatusDescriptor; specification.InternetAccessDescriptor = request.InternetAccessDescriptor; specification.LocalEducationAgencyId = request.LocalEducationAgencyId; specification.MagnetSpecialProgramEmphasisSchoolDescriptor = request.MagnetSpecialProgramEmphasisSchoolDescriptor; specification.SchoolId = request.SchoolId; specification.SchoolTypeDescriptor = request.SchoolTypeDescriptor; specification.TitleIPartASchoolDesignationDescriptor = request.TitleIPartASchoolDesignationDescriptor; } protected override string GetResourceCollectionName() { return "schools"; } protected override string GetReadContentType() { return "application/vnd.ed-fi.school.test-profile-resource-excludeonly.readable+json"; } } }
Code generation needs to be updated to fix the mustache and the generator as follows by moving the attribute to the controller and wrapping with a new ProfileContentType tag as follows:
#if NETCOREAPP using System; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using EdFi.Ods.Api.Attributes; using EdFi.Ods.Api.Controllers; using EdFi.Ods.Api.ExceptionHandling; using EdFi.Ods.Api.Infrastructure.Pipelines.Factories; using EdFi.Ods.Common.Infrastructure; using EdFi.Ods.Common.Models.Requests; using EdFi.Ods.Common.Models.Queries; using EdFi.Ods.Common.Configuration; using EdFi.Ods.Common.Context; using EdFi.Ods.Entities.Common.{{ProperCaseName}}; {{#IsExtensionContext}} using {{ControllersBaseNamespace}}; {{/IsExtensionContext}} using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; {{#Controllers}} namespace {{ControllersNamespace}} { {{#NullRequests}} [ExcludeFromCodeCoverage] public class {{ClassName}} : NullRequestBase { } {{/NullRequests}} [ApiExplorerSettings(IgnoreApi = true)] [ExcludeFromCodeCoverage] [ApiController] [Authorize] {{#ProfileContentType}} [ProfileContentType("{{ContentType}}")] {{/ProfileContentType}} [Route("{{RouteTemplate}}")] public partial class {{ControllerClass}} : DataManagementControllerBase< {{ResourceReadModel}}, {{ResourceWriteModel}}, {{EntityInterface}}, {{AggregateRoot}}, {{PutRequest}}, {{PostRequest}}, {{DeleteRequest}}, {{GetByExampleRequest}}> { public {{ControllerClass}}(IPipelineFactory pipelineFactory, ISchoolYearContextProvider schoolYearContextProvider, IRESTErrorProvider restErrorProvider, IDefaultPageSizeLimitProvider defaultPageSizeLimitProvider) : base(pipelineFactory, schoolYearContextProvider, restErrorProvider, defaultPageSizeLimitProvider) { } protected override void MapAll({{GetByExampleRequest}} request, {{ExtensionNamespacePrefix}}I{{ResourceName}} specification) { {{#MapAllExpression}} {{#HasProperties}} // Copy all existing values specification.SuspendReferenceAssignmentCheck(); {{/HasProperties}} {{#Properties}} specification.{{SpecificationProperty}} = request.{{RequestProperty}}; {{/Properties}} {{^HasProperties}} throw new NotSupportedException("Profile only has a Write Content Type defined for this resource, and so the controller does not support read operations."); {{/HasProperties}} {{/MapAllExpression}} } protected override string GetResourceCollectionName() { return "{{ResourceCollectionName}}"; } {{#ReadContentType}} protected override string GetReadContentType() { return "{{ReadContentType}}"; } {{/ReadContentType}} {{#OverrideHttpFunctions}} [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] public override Task<IActionResult> {{MethodName}}({{MethodParameters}}) { return Task.FromResult<IActionResult>( StatusCode(StatusCodes.Status405MethodNotAllowed, ErrorTranslator .GetErrorMessage("The allowed methods for this resource with the '{{ProfileName}}' profile are {{AllowedHttpMethods}}."))); } {{/OverrideHttpFunctions}} } } {{/Controllers}} #endif