Skip to content

Commit

Permalink
Members and member types in the Management API (#15662)
Browse files Browse the repository at this point in the history
* Members and member types in the Management API

* Add validation endpoints for members

* Include validation result in service response + add unit tests

* Regenerate OpenApi.json

* Regenerate OpenApi.json after merge

* Don't throw an exception when trying to set valid variation levels for member types

* Added missing ProducesResponseType

* Remove TODO, as that works

* Allow creation of member with explicit key

* Do not feature "parent" for member creation + add missing response type

* Do not feature a "Folder" in create member type (folders are not supported)

* Added missing build methods

* Fixed issue with mapping

---------

Co-authored-by: Bjarke Berg <mail@bergmania.dk>
  • Loading branch information
kjac and bergmania committed Feb 5, 2024
1 parent dde2ccf commit 71b3076
Show file tree
Hide file tree
Showing 101 changed files with 4,493 additions and 263 deletions.
Expand Up @@ -64,11 +64,13 @@ status switch
.Build()),
ContentTypeOperationStatus.InvalidComposition => new BadRequestObjectResult(new ProblemDetailsBuilder()
.WithTitle("Invalid composition")
.WithDetail($"The specified {type} type composition is invalid")),
.WithDetail($"The specified {type} type composition is invalid")
.Build()),
ContentTypeOperationStatus.InvalidParent => new BadRequestObjectResult(new ProblemDetailsBuilder()
.WithTitle("Invalid parent")
.WithDetail(
"The specified parent is invalid, or cannot be used in combination with the specified composition/inheritance")),
"The specified parent is invalid, or cannot be used in combination with the specified composition/inheritance")
.Build()),
ContentTypeOperationStatus.DuplicatePropertyTypeAlias => new BadRequestObjectResult(
new ProblemDetailsBuilder()
.WithTitle("Duplicate property type alias")
Expand All @@ -91,7 +93,8 @@ status switch
.Build()),
ContentTypeStructureOperationStatus.NotAllowedByPath => new BadRequestObjectResult(new ProblemDetailsBuilder()
.WithTitle("Not allowed by path")
.WithDetail($"The {type} type operation cannot be performed due to not allowed path (i.e. a child of itself)")),
.WithDetail($"The {type} type operation cannot be performed due to not allowed path (i.e. a child of itself)")
.Build()),
ContentTypeStructureOperationStatus.NotFound => new NotFoundObjectResult(new ProblemDetailsBuilder()
.WithTitle("Not Found")
.WithDetail($"The specified {type} type was not found")
Expand Down
Expand Up @@ -9,6 +9,7 @@
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Features;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Web.Common.Authorization;

Expand Down Expand Up @@ -40,9 +41,10 @@ protected IActionResult CreatedAtAction<T>(Expression<Func<T, string>> action, o
}

protected static Guid CurrentUserKey(IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
return backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key ?? throw new InvalidOperationException("No backoffice user found");
}
=> CurrentUser(backOfficeSecurityAccessor).Key;

protected static IUser CurrentUser(IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
=> backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser ?? throw new InvalidOperationException("No backoffice user found");

/// <summary>
/// Creates a 403 Forbidden result.
Expand Down
Expand Up @@ -24,6 +24,7 @@ protected IActionResult MediaNotFound()
=> NotFound(problemDetailsBuilder
.WithTitle("The requested Media could not be found")
.Build()));

protected IActionResult MediaEditingOperationStatusResult<TContentModelBase>(
ContentEditingOperationStatus status,
TContentModelBase requestModel,
Expand Down
@@ -0,0 +1,38 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Api.Management.Controllers.Member;

[ApiVersion("1.0")]
public class ByKeyMemberController : MemberControllerBase
{
private readonly IMemberEditingService _memberEditingService;
private readonly IMemberPresentationFactory _memberPresentationFactory;

public ByKeyMemberController(IMemberEditingService memberEditingService, IMemberPresentationFactory memberPresentationFactory)
{
_memberEditingService = memberEditingService;
_memberPresentationFactory = memberPresentationFactory;
}

[HttpGet("{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(MemberResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ByKey(Guid id)
{
IMember? member = await _memberEditingService.GetAsync(id);
if (member == null)
{
return MemberNotFound();
}

MemberResponseModel model = await _memberPresentationFactory.CreateResponseModelAsync(member);
return Ok(model);
}
}
@@ -0,0 +1,46 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;

namespace Umbraco.Cms.Api.Management.Controllers.Member;

[ApiVersion("1.0")]
public class CreateMemberController : MemberControllerBase
{
private readonly IMemberEditingPresentationFactory _memberEditingPresentationFactory;
private readonly IMemberEditingService _memberEditingService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public CreateMemberController(
IMemberEditingPresentationFactory memberEditingPresentationFactory,
IMemberEditingService memberEditingService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_memberEditingPresentationFactory = memberEditingPresentationFactory;
_memberEditingService = memberEditingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpPost]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateMemberRequestModel createRequestModel)
{
MemberCreateModel model = _memberEditingPresentationFactory.MapCreateModel(createRequestModel);
Attempt<MemberCreateResult, MemberEditingStatus> result = await _memberEditingService.CreateAsync(model, CurrentUser(_backOfficeSecurityAccessor));

return result.Success
? CreatedAtId<ByKeyMemberController>(controller => nameof(controller.ByKey), result.Result.Content!.Key)
: MemberEditingStatusResult(result.Status);
}
}
@@ -0,0 +1,37 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;

namespace Umbraco.Cms.Api.Management.Controllers.Member;

[ApiVersion("1.0")]
public class DeleteMemberController : MemberControllerBase
{
private readonly IMemberEditingService _memberEditingService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public DeleteMemberController(IMemberEditingService memberEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_memberEditingService = memberEditingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpDelete("{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id)
{
Attempt<IMember?, MemberEditingStatus> result = await _memberEditingService.DeleteAsync(id, CurrentUserKey(_backOfficeSecurityAccessor));

return result.Success
? Ok()
: MemberEditingStatusResult(result.Status);
}
}
@@ -1,32 +1,37 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Api.Management.Controllers.Member.Item;

[ApiVersion("1.0")]
public class ItemMemberItemController : MemberItemControllerBase
{
private readonly IMemberService _memberService;
private readonly IUmbracoMapper _mapper;
private readonly IEntityService _entityService;
private readonly IMemberPresentationFactory _memberPresentationFactory;

public ItemMemberItemController(IMemberService memberService, IUmbracoMapper mapper)
public ItemMemberItemController(IEntityService entityService, IMemberPresentationFactory memberPresentationFactory)
{
_memberService = memberService;
_mapper = mapper;
_entityService = entityService;
_memberPresentationFactory = memberPresentationFactory;
}

[HttpGet("item")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<MemberItemResponseModel>), StatusCodes.Status200OK)]
public async Task<IActionResult> Item([FromQuery(Name = "id")] HashSet<Guid> ids)
{
IEnumerable<IMember> members = await _memberService.GetByKeysAsync(ids.ToArray());
List<MemberItemResponseModel> responseModels = _mapper.MapEnumerable<IMember, MemberItemResponseModel>(members);
return Ok(responseModels);
IEnumerable<IMemberEntitySlim> members = _entityService
.GetAll(UmbracoObjectTypes.Member, ids.ToArray())
.OfType<IMemberEntitySlim>();

IEnumerable<MemberItemResponseModel> responseModels = members.Select(_memberPresentationFactory.CreateItemResponseModel);
return await Task.FromResult(Ok(responseModels));
}
}
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Management.Controllers.Content;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.Content;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Services.OperationStatus;

namespace Umbraco.Cms.Api.Management.Controllers.Member;

[ApiController]
[VersionedApiBackOfficeRoute(Constants.UdiEntityType.Member)]
[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Member))]
// FIXME: implement authorization
// [Authorize(Policy = "New" + AuthorizationPolicies.SectionAccessMembers)]
public class MemberControllerBase : ContentControllerBase
{
protected IActionResult MemberNotFound() => NotFound(new ProblemDetailsBuilder()
.WithTitle("The requested member could not be found")
.Build());

protected IActionResult MemberEditingStatusResult(MemberEditingStatus status)
=> status.MemberEditingOperationStatus is not MemberEditingOperationStatus.Success
? MemberEditingOperationStatusResult(status.MemberEditingOperationStatus)
: status.ContentEditingOperationStatus is not ContentEditingOperationStatus.Success
? ContentEditingOperationStatusResult(status.ContentEditingOperationStatus)
: throw new ArgumentException("Please handle success status explicitly in the controllers", nameof(status));

protected IActionResult MemberEditingOperationStatusResult(MemberEditingOperationStatus status)
=> OperationStatusResult(status, problemDetailsBuilder => status switch
{
MemberEditingOperationStatus.MemberNotFound => NotFound(problemDetailsBuilder
.WithTitle("The requested member could not be found")
.Build()),
MemberEditingOperationStatus.MemberTypeNotFound => NotFound(problemDetailsBuilder
.WithTitle("The requested member type could not be found")
.Build()),
MemberEditingOperationStatus.UnlockFailed => BadRequest(problemDetailsBuilder
.WithTitle("Could not unlock the member")
.WithDetail("Please refer to the logs for additional information.")
.Build()),
MemberEditingOperationStatus.DisableTwoFactorFailed => BadRequest(problemDetailsBuilder
.WithTitle("Could not disable 2FA for the member")
.WithDetail("Please refer to the logs for additional information.")
.Build()),
MemberEditingOperationStatus.RoleAssignmentFailed => BadRequest(problemDetailsBuilder
.WithTitle("Could not update role assignments for the member")
.WithDetail("Please refer to the logs for additional information.")
.Build()),
MemberEditingOperationStatus.PasswordChangeFailed => BadRequest(problemDetailsBuilder
.WithTitle("Could not change the password")
.WithDetail(
"This is likely because the password did not meet the complexity requirements. The logs might hold additional information.")
.Build()),
MemberEditingOperationStatus.InvalidPassword => BadRequest(problemDetailsBuilder
.WithTitle("Invalid password supplied")
.WithDetail("The password did not meet the complexity requirements.")
.Build()),
MemberEditingOperationStatus.InvalidName => BadRequest(problemDetailsBuilder
.WithTitle("Invalid name supplied")
.Build()),
MemberEditingOperationStatus.InvalidUsername => BadRequest(problemDetailsBuilder
.WithTitle("Invalid username supplied")
.Build()),
MemberEditingOperationStatus.InvalidEmail => BadRequest(problemDetailsBuilder
.WithTitle("Invalid email supplied")
.Build()),
MemberEditingOperationStatus.DuplicateUsername => BadRequest(problemDetailsBuilder
.WithTitle("Duplicate username detected")
.WithDetail("The supplied username is already in use by another member.")
.Build()),
MemberEditingOperationStatus.DuplicateEmail => BadRequest(problemDetailsBuilder
.WithTitle("Duplicate email detected")
.WithDetail("The supplied email is already in use by another member.")
.Build()),
MemberEditingOperationStatus.Unknown => StatusCode(
StatusCodes.Status500InternalServerError,
problemDetailsBuilder
.WithTitle("Unknown error. Please see the log for more details.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder
.WithTitle("Unknown member operation status.")
.Build())
});

protected IActionResult MemberEditingOperationStatusResult<TContentModelBase>(
ContentEditingOperationStatus status,
TContentModelBase requestModel,
ContentValidationResult validationResult)
where TContentModelBase : ContentModelBase<MemberValueModel, MemberVariantRequestModel>
=> ContentEditingOperationStatusResult<TContentModelBase, MemberValueModel, MemberVariantRequestModel>(status, requestModel, validationResult);
}
@@ -0,0 +1,52 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;

namespace Umbraco.Cms.Api.Management.Controllers.Member;

[ApiVersion("1.0")]
public class UpdateMemberController : MemberControllerBase
{
private readonly IMemberEditingService _memberEditingService;
private readonly IMemberEditingPresentationFactory _memberEditingPresentationFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public UpdateMemberController(
IMemberEditingService memberEditingService,
IMemberEditingPresentationFactory memberEditingPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_memberEditingService = memberEditingService;
_memberEditingPresentationFactory = memberEditingPresentationFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpPut("{id:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, UpdateMemberRequestModel updateRequestModel)
{
IMember? member = await _memberEditingService.GetAsync(id);
if (member == null)
{
return MemberNotFound();
}

MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(updateRequestModel);
Attempt<MemberUpdateResult, MemberEditingStatus> result = await _memberEditingService.UpdateAsync(member, model, CurrentUser(_backOfficeSecurityAccessor));

return result.Success
? Ok()
: MemberEditingStatusResult(result.Status);
}
}

0 comments on commit 71b3076

Please sign in to comment.