After getting the client-side validation working, I started working on the server-side validation. The namespace is just based on the agency I work for, and a project meant to hold BackOffice logic. You no longer use the UmbracoAuthorizedJsonController for BackOffice controllers, but instead make use of the ManagementApiControllerBase:
namespace AcsWeb.Logic.CustomUrl;
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Management.Controllers;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Authorization;
[ApiVersion("1.0")]
[VersionedApiBackOfficeRoute("custom-url")]
[ApiExplorerSettings(GroupName = "Custom URL API")]
[MapToApi("custom-url")]
[Authorize(AuthorizationPolicies.SectionAccessContent)]
public class CustomUrlApiController :
ManagementApiControllerBase
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IEntityService _entityService;
private readonly IContentService _contentService;
private readonly IUmbracoContextFactory _umbracoContextFactory;
public CustomUrlApiController(
IHttpContextAccessor httpContextAccessor,
IEntityService entityService,
IContentService contentService,
IUmbracoContextFactory umbracoContextFactory)
{
_httpContextAccessor = httpContextAccessor;
_entityService = entityService;
_contentService = contentService;
_umbracoContextFactory = umbracoContextFactory;
}
[HttpPost]
[MapToApiVersion("1.0")]
[ProducesResponseType<CustomUrlVerificationResult>(StatusCodes.Status200OK)]
public CustomUrlVerificationResult Verify(
CustomUrlAliasInput input)
{
string encodedUrl = _httpContextAccessor.HttpContext?.Request.GetEncodedUrl();
var attempt = _entityService.GetId(input.Id ?? Guid.Empty, UmbracoObjectTypes.Document);
int pageId = attempt.ResultOr(0);
var aliasVerificationHelper = new AliasVerificationHelper(encodedUrl, _contentService, _umbracoContextFactory, input.Value, pageId);
return aliasVerificationHelper.GetVerification();
}
}
The CustomUrlVerificationResult
just has a Completed boolean, and a Message string. The CustomUrlAliasInput
has a value string (which is the Custom URL), and an id UmbEntityUnique property which maps to the document GUID (if the document already exists, otherwise this will be empty), and is the CustomUrl custom element value property.
The AliasVerificationHelper makes sure that a duplicate Custom URL hasn’t already been used. In addition to the Management API Controller, I had to create a DataEditor:
namespace AcsWeb.Logic.CustomUrl;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
[DataEditor("AcsWeb.Logic.DataEditor.CustomUrl", ValueEditorIsReusable = false)]
public class CustomUrlDataEditor :
DataEditor
{
public CustomUrlDataEditor(IDataValueEditorFactory dataValueEditorFactory)
: base(dataValueEditorFactory)
{
}
protected override IDataValueEditor CreateValueEditor()
=> DataValueEditorFactory.Create<CustomUrlDataValueEditor>(Attribute!);
}
A CustomUrlDataValueEditor, which transforms the value coming from the custom element to a string value in the database/Umbraco document:
namespace AcsWeb.Logic.CustomUrl;
using System;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Core.Web;
public class CustomUrlDataValueEditor :
DataValueEditor
{
public CustomUrlDataValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute,
IHttpContextAccessor httpContextAccessor,
IEntityService entityService,
IContentService contentService,
IUmbracoContextFactory umbracoContextFactory)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
=> Validators.Add(new CustomUrlValueValidator(httpContextAccessor, entityService, contentService, umbracoContextFactory));
public override object FromEditor(
ContentPropertyData editorValue,
object currentValue)
{
if (editorValue.Value is not JsonObject json)
{
return null;
}
if (!json.ContainsKey("value"))
{
return null;
}
return json["value"]!.GetValue<string>(); // return the string value of the Custom URL
}
public override object ToEditor(
IProperty property,
string culture = null,
string segment = null)
{
var value = property.GetValue(culture, segment);
// the custom-url element's value is an object of { value: String, id: UmbEntityUnique }
// the custom-url LitElement can retrieve the document id from the UMB_ENTITY_DETAIL_WORKSPACE_CONTEXT
// so we just return the same "shape" of the object to stay compatible and give the correct URL value (String)
return new
{
value,
id = Guid.Empty
};
}
}
And a CustomUrlValueValidator, which does the server-side validation on submit, and returns an array of ValidationResults (or an empty array if no errors have occurred):
namespace AcsWeb.Logic.CustomUrl;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
public class CustomUrlValueValidator :
IValueValidator
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IEntityService _entityService;
private readonly IContentService _contentService;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private static readonly string[] ValidationResultInvalidMemberName = ["value"];
public CustomUrlValueValidator(
IHttpContextAccessor httpContextAccessor,
IEntityService entityService,
IContentService contentService,
IUmbracoContextFactory umbracoContextFactory)
{
_httpContextAccessor = httpContextAccessor;
_entityService = entityService;
_contentService = contentService;
_umbracoContextFactory = umbracoContextFactory;
}
public IEnumerable<ValidationResult> Validate(
object value,
string valueType,
object dataTypeConfiguration,
PropertyValidationContext validationContext)
{
if (value is not JsonObject json)
{
return [new ValidationResult("Value is invalid", ValidationResultInvalidMemberName)];
}
if (value.ToString()!.DetectIsEmptyJson())
{
return [new ValidationResult("Value cannot be empty", ValidationResultInvalidMemberName)];
}
if (!json.ContainsKey("value"))
{
return [new ValidationResult("Value is empty or contains an invalid value", ValidationResultInvalidMemberName)];
}
string customUrl = json["value"]!.GetValue<string>();
Guid? id = null;
if (json.ContainsKey("id"))
{
id = json["id"]!.GetValue<Guid>();
}
string encodedUrl = _httpContextAccessor.HttpContext?.Request.GetEncodedUrl();
var attempt = _entityService.GetId(id ?? Guid.Empty, UmbracoObjectTypes.Document);
int pageId = attempt.ResultOr(0);
var aliasVerificationHelper = new AliasVerificationHelper(encodedUrl, _contentService, _umbracoContextFactory, customUrl, pageId);
var result = aliasVerificationHelper.GetVerification();
return result.Completed
? []
: [new ValidationResult(result.Message)];
}
}
Although not related to the custom element, in order to make a Custom URL work (rather than using the natural way Umbraco URLs are generated from their path in the content tree), I also needed to implement the IContentFinder and IUrlProvider interfaces.
This is the umbraco-package.json
file I am using (AcsWeb.Web is my Umbraco UI project):
{
"$schema": "../../umbraco-package-schema.json",
"name": "AcsWeb.Web",
"version": "0.0.1",
"extensions": [
{
"type": "propertyEditorUi",
"alias": "AcsWeb.Web.CustomUrl",
"name": "ACS Edge CMS Custom URL Property Editor",
"element": "/App_Plugins/customurl/customurl.js",
"elementName": "custom-url",
"meta": {
"label": "Custom URL",
"propertyEditorSchemaAlias": "AcsWeb.Logic.DataEditor.CustomUrl",
"icon": "icon-code",
"group": "common"
}
}
]
}
While using Lit and TypeScript to create the Custom URL custom element seemed like an infeasible task, now that I have completed it, I can see how using Web Components and a more web standards based API is a solution that will provide a solution that won’t hold back development of the rest of the Umbraco CMS as changes are made, and new additions created.
I truly want to thank @nielslyngsoe for his insight and help in solving this. I honestly should have posted my original ideas last year, but was overwhelmed as the only full-stack developer on staff. Going back through the Lit documentation, and the current Umbraco documentation and source code, filled in the blanks that I needed to get this to work.
I am looking into updating the documentation on GitHub for some parts that I believe could help others, and am thankful for the Umbraco community and open nature! If anyone has any questions that I could help with, I will do my best.