From d2441d706a7787fa49cc44b8cd06df6ca758408f Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 4 Feb 2026 23:12:32 +0100 Subject: [PATCH 01/24] Basic implementaion --- .../DataType/SchemaDataTypeController.cs | 53 +++++++ .../DataType/DataTypeSchemaResponseModel.cs | 26 ++++ .../ContentPickerPropertyEditor.cs | 15 +- .../PropertyEditors/IValueSchemaProvider.cs | 57 ++++++++ .../PropertyEditors/IntegerPropertyEditor.cs | 37 ++++- .../Services/IPropertyEditorSchemaService.cs | 66 +++++++++ .../UmbracoBuilder.Services.cs | 1 + .../BlockGridPropertyEditorBase.cs | 136 ++++++++++++++++- .../BlockListPropertyEditorBase.cs | 138 +++++++++++++++++- .../MediaPicker3PropertyEditor.cs | 136 ++++++++++++++++- .../PropertyEditors/TextAreaPropertyEditor.cs | 32 +++- .../TrueFalsePropertyEditor.cs | 15 +- .../Implement/PropertyEditorSchemaService.cs | 73 +++++++++ 13 files changed, 778 insertions(+), 7 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaResponseModel.cs create mode 100644 src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs create mode 100644 src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs create mode 100644 src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs new file mode 100644 index 000000000000..a46bd4bbf488 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs @@ -0,0 +1,53 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.DataType; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DataType; + +/// +/// Controller for retrieving data type value schemas. +/// +[ApiVersion("1.0")] +public class SchemaDataTypeController : DataTypeControllerBase +{ + private readonly IPropertyEditorSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + /// The property editor schema service. + public SchemaDataTypeController(IPropertyEditorSchemaService schemaService) + => _schemaService = schemaService; + + /// + /// Gets the value schema for a data type. + /// + /// The unique identifier of the data type. + /// The schema information for the data type's values. + /// + /// Returns schema information for property editors that implement IValueSchemaProvider. + /// Returns 404 if the data type is not found or doesn't support schema information. + /// + [HttpGet("{id:guid}/schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(DataTypeSchemaResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Schema(Guid id) + { + var valueType = await _schemaService.GetValueTypeAsync(id); + var jsonSchema = await _schemaService.GetValueSchemaAsync(id); + + if (valueType is null && jsonSchema is null) + { + return DataTypeNotFound(); + } + + return Ok(new DataTypeSchemaResponseModel + { + ValueTypeName = valueType?.FullName, + JsonSchema = jsonSchema, + }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaResponseModel.cs new file mode 100644 index 000000000000..956f349283d0 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaResponseModel.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Api.Management.ViewModels.DataType; + +/// +/// Represents schema information for a data type's stored values. +/// +public class DataTypeSchemaResponseModel +{ + /// + /// Gets or sets the full name of the CLR type representing stored values. + /// + /// + /// This can be null if the property editor doesn't provide type information + /// or if the type varies significantly based on configuration. + /// + public string? ValueTypeName { get; set; } + + /// + /// Gets or sets the JSON Schema (draft 2020-12) describing the value structure. + /// + /// + /// This can be null if the property editor doesn't provide schema information. + /// + public JsonObject? JsonSchema { get; set; } +} diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs index 747ab67d82d5..6ae1b42c8d83 100644 --- a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; @@ -20,7 +21,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.ContentPicker, ValueType = ValueTypes.String, ValueEditorIsReusable = true)] -public class ContentPickerPropertyEditor : DataEditor +public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -36,6 +37,18 @@ public ContentPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactor SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["pattern"] = "^umb:\\/\\/document\\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", + ["description"] = "UDI reference to a document (e.g., umb://document/a1b2c3d4-e5f6-7890-1234-567890abcdef)", + }; + /// protected override IConfigurationEditor CreateConfigurationEditor() => new ContentPickerConfigurationEditor(_ioHelper); diff --git a/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs b/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs new file mode 100644 index 000000000000..d82f27480179 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides schema information about the values a property editor accepts and stores. +/// +/// +/// +/// This interface is opt-in for property editors that want to expose their value schema +/// for programmatic content creation, validation, and tooling support. +/// +/// +/// Implementations should return the schema for the database/stored model (what +/// produces), not the editor model (what produces) or the published model +/// (what produces). +/// +/// +public interface IValueSchemaProvider +{ + /// + /// Gets the CLR type that represents the stored value structure. + /// + /// The data type configuration, which may affect the value type. + /// + /// The CLR type of the stored value, or null if the type cannot be determined + /// or varies significantly based on configuration. + /// + /// + /// + /// For simple editors (e.g., textbox), this might return . + /// For complex editors (e.g., MediaPicker3), this returns the DTO type. + /// For block-based editors where the structure is entirely configuration-dependent, this may return null. + /// + /// + Type? GetValueType(object? configuration); + + /// + /// Gets a JSON Schema (draft 2020-12) describing the value structure. + /// + /// The data type configuration, which may affect the schema. + /// + /// A containing a valid JSON Schema, or null if the schema + /// cannot be generated for the given configuration. + /// + /// + /// + /// The returned schema should describe the structure that produces + /// and that can be passed to when creating content programmatically. + /// + /// + /// For configuration-dependent schemas (e.g., BlockList with specific element types), the schema + /// should reflect the constraints defined in the configuration. + /// + /// + JsonObject? GetValueSchema(object? configuration); +} diff --git a/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs index fd0ff9315833..b72cc701e57f 100644 --- a/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/IntegerPropertyEditor.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -20,7 +21,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.Integer, ValueType = ValueTypes.Integer, ValueEditorIsReusable = true)] -public class IntegerPropertyEditor : DataEditor +public class IntegerPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -29,6 +30,40 @@ public IntegerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(int?); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("integer", "null"), + }; + + // Add min/max constraints from configuration if available + if (configuration is IDictionary configDict) + { + if (configDict.TryGetValue("min", out var minValue) && minValue is int min) + { + schema["minimum"] = min; + } + + if (configDict.TryGetValue("max", out var maxValue) && maxValue is int max) + { + schema["maximum"] = max; + } + + if (configDict.TryGetValue("step", out var stepValue) && stepValue is int step && step > 1) + { + schema["multipleOf"] = step; + } + } + + return schema; + } + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs new file mode 100644 index 000000000000..03d42c7d148f --- /dev/null +++ b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Provides services for querying property editor value schemas. +/// +/// +/// +/// This service enables retrieval of JSON Schema information for property editor values, +/// supporting programmatic content creation, import validation, and tooling. +/// +/// +/// Schema information is only available for property editors that implement . +/// +/// +public interface IPropertyEditorSchemaService : IService +{ + /// + /// Gets the CLR type for a specific data type's stored values. + /// + /// The unique key of the data type. + /// + /// The CLR type of values stored by this data type, or null if the type cannot be determined + /// or the data type's property editor doesn't implement . + /// + Task GetValueTypeAsync(Guid dataTypeKey); + + /// + /// Gets the JSON Schema for a specific data type's stored values. + /// + /// The unique key of the data type. + /// + /// A JSON Schema (draft 2020-12) describing the value structure, or null if the schema cannot be generated + /// or the data type's property editor doesn't implement . + /// + Task GetValueSchemaAsync(Guid dataTypeKey); + + /// + /// Gets the CLR type for a property editor with the specified configuration. + /// + /// The alias of the property editor. + /// The configuration object for the data type. + /// + /// The CLR type, or null if the property editor doesn't implement . + /// + Type? GetValueType(string propertyEditorAlias, object? configuration); + + /// + /// Gets the JSON Schema for a property editor with the specified configuration. + /// + /// The alias of the property editor. + /// The configuration object for the data type. + /// + /// A JSON Schema (draft 2020-12), or null if the property editor doesn't implement . + /// + JsonObject? GetValueSchema(string propertyEditorAlias, object? configuration); + + /// + /// Checks whether a property editor supports schema information. + /// + /// The alias of the property editor. + /// true if the editor implements ; otherwise, false. + bool SupportsSchema(string propertyEditorAlias); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 2b569ddd5ab6..5fa0248f95c4 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -87,6 +87,7 @@ internal static IUmbracoBuilder AddServices(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); #pragma warning disable CS0618 // Type or member is obsolete // TODO (V18): Replace this with MarkdigMarkdownToHtmlConverter as the default implementation. diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index 19e518cdcbf8..fa63a881b19b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Cache.PropertyEditors; @@ -21,7 +22,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// /// Abstract base class for block grid based editors. /// -public abstract class BlockGridPropertyEditorBase : DataEditor +public abstract class BlockGridPropertyEditorBase : DataEditor, IValueSchemaProvider { private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory; @@ -34,6 +35,139 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac public override IPropertyIndexValueFactory PropertyIndexValueFactory => _blockValuePropertyIndexValueFactory; + /// + public virtual Type? GetValueType(object? configuration) => typeof(string); // JSON string representation + + /// + public virtual JsonObject? GetValueSchema(object? configuration) + { + var config = configuration as BlockGridConfiguration; + + // Build area item schema + var areaItemSchema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["items"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["$ref"] = "#/$defs/layoutItem" }, + }, + }, + }; + + // Build layout item schema (with grid-specific properties) + var layoutItemSchema = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("contentUdi"), + ["properties"] = new JsonObject + { + ["contentUdi"] = new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, + ["settingsUdi"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "null" }, + new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, + }, + }, + ["columnSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, + ["rowSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, + ["areas"] = new JsonObject + { + ["type"] = "array", + ["items"] = areaItemSchema, + }, + }, + }; + + // Build block item data schema + var blockItemDataSchema = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("key", "contentTypeKey"), + ["properties"] = new JsonObject + { + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["values"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject { ["type"] = "string" }, + ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["value"] = new JsonObject { }, // Any type - depends on property editor + }, + }, + }, + }, + }; + + // Build the main schema + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["$defs"] = new JsonObject + { + ["layoutItem"] = layoutItemSchema, + }, + ["properties"] = new JsonObject + { + ["layout"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + [Constants.PropertyEditors.Aliases.BlockGrid] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["$ref"] = "#/$defs/layoutItem" }, + }, + }, + }, + ["contentData"] = new JsonObject + { + ["type"] = "array", + ["items"] = blockItemDataSchema, + }, + ["settingsData"] = new JsonObject + { + ["type"] = "array", + ["items"] = blockItemDataSchema, + }, + }, + }; + + // Add grid columns constraint from configuration + if (config?.GridColumns is int gridColumns && gridColumns > 0) + { + layoutItemSchema["properties"]!["columnSpan"]!.AsObject()["maximum"] = gridColumns; + } + + // Add validation constraints + if (config?.ValidationLimit?.Min is int min && min > 0) + { + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockGrid]!.AsObject(); + layoutArray["minItems"] = min; + } + + if (config?.ValidationLimit?.Max is int max && max > 0) + { + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockGrid]!.AsObject(); + layoutArray["maxItems"] = max; + } + + return schema; + } #region Value Editor diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index 322d2927fb7a..afa42c064830 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Cache.PropertyEditors; @@ -17,7 +18,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// /// Abstract base class for block list based editors. /// -public abstract class BlockListPropertyEditorBase : DataEditor +public abstract class BlockListPropertyEditorBase : DataEditor, IValueSchemaProvider { private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory; private readonly IJsonSerializer _jsonSerializer; @@ -36,6 +37,141 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac /// public override IPropertyIndexValueFactory PropertyIndexValueFactory => _blockValuePropertyIndexValueFactory; + /// + public virtual Type? GetValueType(object? configuration) => typeof(string); // JSON string representation + + /// + public virtual JsonObject? GetValueSchema(object? configuration) + { + var config = configuration as BlockListConfiguration; + + // Build layout item schema + var layoutItemSchema = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("contentUdi"), + ["properties"] = new JsonObject + { + ["contentUdi"] = new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, + ["settingsUdi"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "null" }, + new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, + }, + }, + }, + }; + + // Build block item data schema + var blockItemDataSchema = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("key", "contentTypeKey"), + ["properties"] = new JsonObject + { + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["values"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject { ["type"] = "string" }, + ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["value"] = new JsonObject { }, // Any type - depends on property editor + }, + }, + }, + }, + }; + + // Build expose schema + var exposeItemSchema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["cultures"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } }, + ["segments"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = new JsonArray("string", "null") } }, + }, + }; + + // Add contentTypeKey constraints if blocks are configured + if (config?.Blocks is { Length: > 0 }) + { + var allowedContentTypes = new JsonArray(); + var allowedSettingsTypes = new JsonArray(); + + foreach (var block in config.Blocks) + { + allowedContentTypes.Add(JsonValue.Create(block.ContentElementTypeKey.ToString())); + if (block.SettingsElementTypeKey.HasValue) + { + allowedSettingsTypes.Add(JsonValue.Create(block.SettingsElementTypeKey.Value.ToString())); + } + } + } + + // Build the main schema + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["layout"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + [Constants.PropertyEditors.Aliases.BlockList] = new JsonObject + { + ["type"] = "array", + ["items"] = layoutItemSchema, + }, + }, + }, + ["contentData"] = new JsonObject + { + ["type"] = "array", + ["items"] = blockItemDataSchema, + }, + ["settingsData"] = new JsonObject + { + ["type"] = "array", + ["items"] = blockItemDataSchema, + }, + ["expose"] = new JsonObject + { + ["type"] = "array", + ["items"] = exposeItemSchema, + }, + }, + }; + + // Add validation constraints + if (config?.ValidationLimit.Min is int min && min > 0) + { + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockList]!.AsObject(); + layoutArray["minItems"] = min; + } + + if (config?.ValidationLimit.Max is int max && max > 0) + { + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockList]!.AsObject(); + layoutArray["maxItems"] = max; + } + + return schema; + } + /// /// Instantiates a new for use with the block list editor property value editor. /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index 50fe850e04f2..efd8c02563ec 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -24,7 +25,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MediaPicker3, ValueType = ValueTypes.Json, ValueEditorIsReusable = true)] -public class MediaPicker3PropertyEditor : DataEditor +public class MediaPicker3PropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -41,6 +42,139 @@ public MediaPicker3PropertyEditor(IDataValueEditorFactory dataValueEditorFactory /// public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + /// + public Type? GetValueType(object? configuration) => typeof(string); // JSON string representation + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var config = configuration as MediaPicker3Configuration; + + // Build the item schema for individual media items + var itemSchema = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("key", "mediaKey"), + ["properties"] = new JsonObject + { + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["mediaKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["mediaTypeAlias"] = new JsonObject { ["type"] = "string" }, + ["crops"] = BuildCropsSchema(config), + ["focalPoint"] = BuildFocalPointSchema(config), + }, + }; + + // Build the array schema + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = itemSchema, + }; + + // Add min/max items constraints from configuration + if (config?.ValidationLimit.Min is int min && min > 0) + { + schema["minItems"] = min; + } + + if (config?.ValidationLimit.Max is int max && max > 0) + { + schema["maxItems"] = max; + } + + if (config?.Multiple == false) + { + schema["maxItems"] = 1; + } + + return schema; + } + + private static JsonObject BuildCropsSchema(MediaPicker3Configuration? config) + { + var cropItemSchema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject { ["type"] = "string" }, + ["width"] = new JsonObject { ["type"] = "integer" }, + ["height"] = new JsonObject { ["type"] = "integer" }, + ["coordinates"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "null" }, + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["x1"] = new JsonObject { ["type"] = "number" }, + ["y1"] = new JsonObject { ["type"] = "number" }, + ["x2"] = new JsonObject { ["type"] = "number" }, + ["y2"] = new JsonObject { ["type"] = "number" }, + }, + }, + }, + }, + }, + }; + + // If crops are configured, add enum constraint for crop aliases + if (config?.Crops is { Length: > 0 }) + { + var cropAliases = new JsonArray(); + foreach (var crop in config.Crops) + { + if (!string.IsNullOrEmpty(crop.Alias)) + { + cropAliases.Add(JsonValue.Create(crop.Alias)); + } + } + + if (cropAliases.Count > 0) + { + var aliasProperty = cropItemSchema["properties"]!["alias"]!.AsObject(); + aliasProperty["enum"] = cropAliases; + } + } + + return new JsonObject + { + ["type"] = new JsonArray("array", "null"), + ["items"] = cropItemSchema, + }; + } + + private static JsonObject BuildFocalPointSchema(MediaPicker3Configuration? config) + { + // If focal point is disabled, always null + if (config?.EnableLocalFocalPoint == false) + { + return new JsonObject { ["type"] = "null" }; + } + + return new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "null" }, + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["left"] = new JsonObject { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 }, + ["top"] = new JsonObject { ["type"] = "number", ["minimum"] = 0, ["maximum"] = 1 }, + }, + }, + }, + }; + } + /// protected override IConfigurationEditor CreateConfigurationEditor() => new MediaPicker3ConfigurationEditor(_ioHelper); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TextAreaPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TextAreaPropertyEditor.cs index a5c8c8627dba..42b5a3b48e98 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TextAreaPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TextAreaPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -13,7 +14,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.TextArea, ValueType = ValueTypes.Text, ValueEditorIsReusable = true)] -public class TextAreaPropertyEditor : DataEditor +public class TextAreaPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -27,6 +28,35 @@ public TextAreaPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, II SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + }; + + // Add maxLength constraint from configuration if available + if (configuration is TextAreaConfiguration textAreaConfig && textAreaConfig.MaxChars > 0) + { + schema["maxLength"] = textAreaConfig.MaxChars; + } + else if (configuration is IDictionary configDict && + configDict.TryGetValue("maxChars", out var maxCharsValue)) + { + if (maxCharsValue is int maxChars && maxChars > 0) + { + schema["maxLength"] = maxChars; + } + } + + return schema; + } + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs index 705b51befde2..0c48f8612c68 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -17,7 +18,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.Boolean, ValueType = ValueTypes.Integer, ValueEditorIsReusable = true)] -public class TrueFalsePropertyEditor : DataEditor +public class TrueFalsePropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -26,6 +27,18 @@ public TrueFalsePropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(int); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = "integer", + ["enum"] = new JsonArray(0, 1), + ["description"] = "Boolean value stored as integer: 0 = false, 1 = true", + }; + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs new file mode 100644 index 000000000000..c9edd36c137d --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.Services.Implement; + +/// +/// Provides services for querying property editor value schemas. +/// +internal sealed class PropertyEditorSchemaService : IPropertyEditorSchemaService +{ + private readonly IDataTypeService _dataTypeService; + private readonly DataEditorCollection _dataEditors; + + /// + /// Initializes a new instance of the class. + /// + public PropertyEditorSchemaService( + IDataTypeService dataTypeService, + DataEditorCollection dataEditors) + { + _dataTypeService = dataTypeService; + _dataEditors = dataEditors; + } + + /// + public async Task GetValueTypeAsync(Guid dataTypeKey) + { + var dataType = await _dataTypeService.GetAsync(dataTypeKey); + if (dataType is null) + { + return null; + } + + return GetValueType(dataType.EditorAlias, dataType.ConfigurationObject); + } + + /// + public async Task GetValueSchemaAsync(Guid dataTypeKey) + { + var dataType = await _dataTypeService.GetAsync(dataTypeKey); + if (dataType is null) + { + return null; + } + + return GetValueSchema(dataType.EditorAlias, dataType.ConfigurationObject); + } + + /// + public Type? GetValueType(string propertyEditorAlias, object? configuration) + { + IValueSchemaProvider? provider = GetSchemaProvider(propertyEditorAlias); + return provider?.GetValueType(configuration); + } + + /// + public JsonObject? GetValueSchema(string propertyEditorAlias, object? configuration) + { + IValueSchemaProvider? provider = GetSchemaProvider(propertyEditorAlias); + return provider?.GetValueSchema(configuration); + } + + /// + public bool SupportsSchema(string propertyEditorAlias) + => GetSchemaProvider(propertyEditorAlias) is not null; + + private IValueSchemaProvider? GetSchemaProvider(string propertyEditorAlias) + { + IDataEditor? editor = _dataEditors.FirstOrDefault(e => e.Alias == propertyEditorAlias); + return editor as IValueSchemaProvider; + } +} From 8d8b8cf20c3b450a7561792a7ccfa8cf25d27d8b Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 5 Feb 2026 11:19:07 +0100 Subject: [PATCH 02/24] Tests and schema validation --- Directory.Packages.props | 1 + .../DataType/DataTypeControllerBase.cs | 7 + .../Services/IPropertyEditorSchemaService.cs | 15 + .../Services/SchemaValidationResult.cs | 35 ++ .../Implement/PropertyEditorSchemaService.cs | 125 +++++++ .../Umbraco.Infrastructure.csproj | 1 + .../PropertyEditorSchemaServiceTests.cs | 217 ++++++++++++ .../ValueSchemaProviderTests.cs | 167 +++++++++ .../PropertyEditorSchemaServiceTests.cs | 321 ++++++++++++++++++ 9 files changed, 889 insertions(+) create mode 100644 src/Umbraco.Core/Services/SchemaValidationResult.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8a32d0745480..d5ee0d7f8dc6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -49,6 +49,7 @@ + diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs index 84f70e91c49a..82e8faeb3e21 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs @@ -55,8 +55,15 @@ protected IActionResult DataTypeOperationStatusResult(DataTypeOperationStatus st protected IActionResult DataTypeNotFound() => OperationStatusResult(DataTypeOperationStatus.NotFound, DataTypeNotFound); + protected IActionResult SchemaNotFound() => OperationStatusResult(DataTypeOperationStatus.NotFound, SchemaNotFound); + private IActionResult DataTypeNotFound(ProblemDetailsBuilder problemDetailsBuilder) => NotFound(problemDetailsBuilder .WithTitle("The data type could not be found") .Build()); + + private IActionResult SchemaNotFound(ProblemDetailsBuilder problemDetailsBuilder) + => NotFound(problemDetailsBuilder + .WithTitle("The data type schema definition could not be found") + .Build()); } diff --git a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs index 03d42c7d148f..4530d12597b1 100644 --- a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs +++ b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs @@ -63,4 +63,19 @@ public interface IPropertyEditorSchemaService : IService /// The alias of the property editor. /// true if the editor implements ; otherwise, false. bool SupportsSchema(string propertyEditorAlias); + + /// + /// Validates a value against the JSON Schema for a specific data type. + /// + /// The unique key of the data type. + /// The value to validate, as a JSON string or JSON-compatible object. + /// + /// A collection of validation results. Returns empty if validation passes, or if the data type + /// doesn't support schema validation. Returns errors if the value doesn't conform to the schema. + /// + /// + /// If the data type's property editor doesn't implement or doesn't provide + /// a schema, this method returns an empty collection (validation passes by default). + /// + Task> ValidateValueAsync(Guid dataTypeKey, object? value); } diff --git a/src/Umbraco.Core/Services/SchemaValidationResult.cs b/src/Umbraco.Core/Services/SchemaValidationResult.cs new file mode 100644 index 000000000000..5ea5f52c3bac --- /dev/null +++ b/src/Umbraco.Core/Services/SchemaValidationResult.cs @@ -0,0 +1,35 @@ +namespace Umbraco.Cms.Core.Services; + +/// +/// Represents a validation error from JSON Schema validation. +/// +public sealed class SchemaValidationResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The validation error message. + /// The JSON path to the invalid value. + /// The JSON Schema keyword that failed validation. + public SchemaValidationResult(string message, string? path = null, string? keyword = null) + { + Message = message; + Path = path; + Keyword = keyword; + } + + /// + /// Gets the validation error message. + /// + public string Message { get; } + + /// + /// Gets the JSON path to the invalid value (e.g., "$.items[0].name"). + /// + public string? Path { get; } + + /// + /// Gets the JSON Schema keyword that failed validation (e.g., "type", "required", "minLength"). + /// + public string? Keyword { get; } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs index c9edd36c137d..4ebf74d159cd 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using System.Text.Json.Nodes; +using Json.Schema; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; @@ -65,6 +67,129 @@ public PropertyEditorSchemaService( public bool SupportsSchema(string propertyEditorAlias) => GetSchemaProvider(propertyEditorAlias) is not null; + /// + public async Task> ValidateValueAsync(Guid dataTypeKey, object? value) + { + JsonObject? schemaJson = await GetValueSchemaAsync(dataTypeKey); + if (schemaJson is null) + { + // No schema available - validation passes by default + return []; + } + + try + { + // Parse the schema + JsonSchema schema = JsonSchema.FromText(schemaJson.ToJsonString()); + + // Convert value to JsonNode for evaluation + JsonNode? valueNode = ConvertToJsonNode(value); + + // Evaluate the value against the schema + EvaluationOptions options = new() + { + OutputFormat = OutputFormat.List, + }; + + EvaluationResults results = schema.Evaluate(valueNode, options); + + if (results.IsValid) + { + return []; + } + + // Collect validation errors + return ExtractValidationErrors(results); + } + catch (JsonException ex) + { + return [new SchemaValidationResult($"Invalid JSON: {ex.Message}")]; + } + } + + private static JsonNode? ConvertToJsonNode(object? value) + { + if (value is null) + { + return null; + } + + if (value is JsonNode node) + { + return node; + } + + if (value is string stringValue) + { + // Try to parse as JSON, otherwise treat as string literal + try + { + return JsonNode.Parse(stringValue); + } + catch (JsonException) + { + return JsonValue.Create(stringValue); + } + } + + // Serialize other objects to JSON and parse + var json = JsonSerializer.Serialize(value); + return JsonNode.Parse(json); + } + + private static List ExtractValidationErrors(EvaluationResults results) + { + var errors = new List(); + + if (results.Details is null || results.Details.Count == 0) + { + // No details, create a generic error from the top-level result + if (!results.IsValid && results.Errors is not null) + { + foreach (var error in results.Errors) + { + errors.Add(new SchemaValidationResult( + error.Value, + results.InstanceLocation?.ToString(), + error.Key)); + } + } + else if (!results.IsValid) + { + errors.Add(new SchemaValidationResult("Value does not conform to schema")); + } + + return errors; + } + + // Process nested results + foreach (EvaluationResults detail in results.Details) + { + if (detail.IsValid) + { + continue; + } + + if (detail.Errors is not null && detail.Errors.Count > 0) + { + foreach (var error in detail.Errors) + { + errors.Add(new SchemaValidationResult( + error.Value, + detail.InstanceLocation?.ToString(), + error.Key)); + } + } + else + { + // Recursively check nested details + errors.AddRange(ExtractValidationErrors(detail)); + } + } + + return errors; + } + private IValueSchemaProvider? GetSchemaProvider(string propertyEditorAlias) { IDataEditor? editor = _dataEditors.FirstOrDefault(e => e.Alias == propertyEditorAlias); diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index 171c873e3d3f..de7fc1ec4ad2 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -37,6 +37,7 @@ + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs new file mode 100644 index 000000000000..0d74baf9f537 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -0,0 +1,217 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +/// +/// Integration tests for the . +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class PropertyEditorSchemaServiceTests : UmbracoIntegrationTest +{ + private IPropertyEditorSchemaService PropertyEditorSchemaService => GetRequiredService(); + + private IDataTypeService DataTypeService => GetRequiredService(); + + private IDataValueEditorFactory DataValueEditorFactory => GetRequiredService(); + + private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => + GetRequiredService(); + + [Test] + public void SupportsSchema_Returns_True_For_Integer_Editor() + { + // Act + var result = PropertyEditorSchemaService.SupportsSchema(Constants.PropertyEditors.Aliases.Integer); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void SupportsSchema_Returns_True_For_ContentPicker_Editor() + { + // Act + var result = PropertyEditorSchemaService.SupportsSchema(Constants.PropertyEditors.Aliases.ContentPicker); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public async Task GetValueSchemaAsync_Returns_Schema_For_Integer_DataType() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer", + DatabaseType = ValueStorageType.Integer, + ConfigurationData = new Dictionary + { + { "min", 0 }, + { "max", 100 }, + { "step", 1 }, + }, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var schema = await PropertyEditorSchemaService.GetValueSchemaAsync(dataType.Key); + + // Assert + Assert.That(schema, Is.Not.Null); + Assert.That(schema!["$schema"]?.GetValue(), Is.EqualTo("https://json-schema.org/draft/2020-12/schema")); + Assert.That(schema["minimum"]?.GetValue(), Is.EqualTo(0)); + Assert.That(schema["maximum"]?.GetValue(), Is.EqualTo(100)); + } + + [Test] + public async Task GetValueTypeAsync_Returns_Type_For_Integer_DataType() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer Type", + DatabaseType = ValueStorageType.Integer, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var valueType = await PropertyEditorSchemaService.GetValueTypeAsync(dataType.Key); + + // Assert + Assert.That(valueType, Is.EqualTo(typeof(int?))); + } + + [Test] + public async Task GetValueSchemaAsync_Returns_Null_For_NonExistent_DataType() + { + // Act + var schema = await PropertyEditorSchemaService.GetValueSchemaAsync(Guid.NewGuid()); + + // Assert + Assert.That(schema, Is.Null); + } + + [Test] + public async Task ValidateValueAsync_Returns_Empty_For_Valid_Integer_Value() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer Validation", + DatabaseType = ValueStorageType.Integer, + ConfigurationData = new Dictionary + { + { "min", 0 }, + { "max", 100 }, + }, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "50"); + + // Assert + Assert.That(results, Is.Empty); + } + + [Test] + public async Task ValidateValueAsync_Returns_Errors_For_Out_Of_Range_Integer() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer Range Validation", + DatabaseType = ValueStorageType.Integer, + ConfigurationData = new Dictionary + { + { "min", 0 }, + { "max", 100 }, + }, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "150"); + + // Assert + Assert.That(results, Is.Not.Empty); + Assert.That(results.Any(r => r.Keyword == "maximum"), Is.True); + } + + [Test] + public async Task ValidateValueAsync_Returns_Errors_For_Invalid_Type() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer Type Validation", + DatabaseType = ValueStorageType.Integer, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "\"not an integer\""); + + // Assert + Assert.That(results, Is.Not.Empty); + Assert.That(results.Any(r => r.Keyword == "type"), Is.True); + } + + [Test] + public async Task ValidateValueAsync_Returns_Empty_For_Null_Value_When_Nullable() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer Null Validation", + DatabaseType = ValueStorageType.Integer, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "null"); + + // Assert + Assert.That(results, Is.Empty); + } + + [Test] + public async Task ValidateValueAsync_Returns_Empty_For_NonExistent_DataType() + { + // Act + var results = await PropertyEditorSchemaService.ValidateValueAsync(Guid.NewGuid(), "any value"); + + // Assert - No schema means validation passes by default + Assert.That(results, Is.Empty); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs new file mode 100644 index 000000000000..7ccfa071e70a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs @@ -0,0 +1,167 @@ +using System.Text.Json.Nodes; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class ValueSchemaProviderTests +{ + [Test] + public void IntegerPropertyEditor_Returns_Integer_Schema() + { + // Arrange + var editor = CreateIntegerPropertyEditor(); + + // Act + var schema = editor.GetValueSchema(null); + + // Assert + Assert.That(schema, Is.Not.Null); + Assert.That(schema!["$schema"]?.GetValue(), Is.EqualTo("https://json-schema.org/draft/2020-12/schema")); + + var typeArray = schema["type"] as JsonArray; + Assert.That(typeArray, Is.Not.Null); + Assert.That(typeArray!.Select(t => t?.GetValue()), Is.EquivalentTo(new[] { "integer", "null" })); + } + + [Test] + public void IntegerPropertyEditor_Returns_ValueType() + { + // Arrange + var editor = CreateIntegerPropertyEditor(); + + // Act + var valueType = editor.GetValueType(null); + + // Assert + Assert.That(valueType, Is.EqualTo(typeof(int?))); + } + + [Test] + public void IntegerPropertyEditor_Includes_MinMax_When_Configured() + { + // Arrange + var editor = CreateIntegerPropertyEditor(); + var config = new Dictionary + { + { "min", 10 }, + { "max", 100 }, + }; + + // Act + var schema = editor.GetValueSchema(config); + + // Assert + Assert.That(schema, Is.Not.Null); + Assert.That(schema!["minimum"]?.GetValue(), Is.EqualTo(10)); + Assert.That(schema["maximum"]?.GetValue(), Is.EqualTo(100)); + } + + [Test] + public void IntegerPropertyEditor_Includes_Step_When_Greater_Than_One() + { + // Arrange + var editor = CreateIntegerPropertyEditor(); + var config = new Dictionary + { + { "min", 0 }, + { "step", 5 }, + }; + + // Act + var schema = editor.GetValueSchema(config); + + // Assert + Assert.That(schema, Is.Not.Null); + Assert.That(schema!["multipleOf"]?.GetValue(), Is.EqualTo(5)); + } + + [Test] + public void IntegerPropertyEditor_Omits_Step_When_One() + { + // Arrange + var editor = CreateIntegerPropertyEditor(); + var config = new Dictionary + { + { "step", 1 }, + }; + + // Act + var schema = editor.GetValueSchema(config); + + // Assert + Assert.That(schema, Is.Not.Null); + Assert.That(schema!.ContainsKey("multipleOf"), Is.False); + } + + [Test] + public void ContentPickerPropertyEditor_Returns_String_Schema_With_UDI_Pattern() + { + // Arrange + var editor = CreateContentPickerPropertyEditor(); + + // Act + var schema = editor.GetValueSchema(null); + + // Assert + Assert.That(schema, Is.Not.Null); + Assert.That(schema!["$schema"]?.GetValue(), Is.EqualTo("https://json-schema.org/draft/2020-12/schema")); + + var typeArray = schema["type"] as JsonArray; + Assert.That(typeArray, Is.Not.Null); + Assert.That(typeArray!.Select(t => t?.GetValue()), Is.EquivalentTo(new[] { "string", "null" })); + + var pattern = schema["pattern"]?.GetValue(); + Assert.That(pattern, Is.Not.Null); + // The pattern uses escaped forward slashes in the regex + Assert.That(pattern, Contains.Substring("umb:").And.Contains("document")); + } + + [Test] + public void ContentPickerPropertyEditor_Returns_ValueType_String() + { + // Arrange + var editor = CreateContentPickerPropertyEditor(); + + // Act + var valueType = editor.GetValueType(null); + + // Assert + Assert.That(valueType, Is.EqualTo(typeof(string))); + } + + private static IntegerPropertyEditor CreateIntegerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.Integer))); + + return new IntegerPropertyEditor(dataValueEditorFactory); + } + + private static ContentPickerPropertyEditor CreateContentPickerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.ContentPicker))); + + return new ContentPickerPropertyEditor( + dataValueEditorFactory, + Mock.Of()); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs new file mode 100644 index 000000000000..1b37f072c5dc --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -0,0 +1,321 @@ +using System.Text.Json.Nodes; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Services.Implement; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services; + +[TestFixture] +public class PropertyEditorSchemaServiceTests +{ + private Mock _dataTypeServiceMock = null!; + private List _dataEditors = null!; + private PropertyEditorSchemaService _sut = null!; + + [SetUp] + public void SetUp() + { + _dataTypeServiceMock = new Mock(); + _dataEditors = new List(); + var dataEditorCollection = new DataEditorCollection(() => _dataEditors); + _sut = new PropertyEditorSchemaService(_dataTypeServiceMock.Object, dataEditorCollection); + } + + [Test] + public void SupportsSchema_Returns_True_For_Editor_Implementing_IValueSchemaProvider() + { + // Arrange + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + + // Act + var result = _sut.SupportsSchema("test.schemaProvider"); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void SupportsSchema_Returns_False_For_Editor_Not_Implementing_IValueSchemaProvider() + { + // Arrange + var regularEditor = new MockRegularEditor(); + SetupDataEditors(regularEditor); + + // Act + var result = _sut.SupportsSchema("test.regular"); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void SupportsSchema_Returns_False_For_Unknown_Editor() + { + // Arrange + SetupDataEditors(); + + // Act + var result = _sut.SupportsSchema("unknown.editor"); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void GetValueType_Returns_Type_From_Provider() + { + // Arrange + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + + // Act + var result = _sut.GetValueType("test.schemaProvider", null); + + // Assert + Assert.That(result, Is.EqualTo(typeof(string))); + } + + [Test] + public void GetValueType_Returns_Null_For_NonProvider_Editor() + { + // Arrange + var regularEditor = new MockRegularEditor(); + SetupDataEditors(regularEditor); + + // Act + var result = _sut.GetValueType("test.regular", null); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void GetValueSchema_Returns_Schema_From_Provider() + { + // Arrange + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + + // Act + var result = _sut.GetValueSchema("test.schemaProvider", null); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result!["type"]?.GetValue(), Is.EqualTo("string")); + } + + [Test] + public void GetValueSchema_Returns_Null_For_NonProvider_Editor() + { + // Arrange + var regularEditor = new MockRegularEditor(); + SetupDataEditors(regularEditor); + + // Act + var result = _sut.GetValueSchema("test.regular", null); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetValueTypeAsync_Retrieves_DataType_And_Returns_Type() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + + // Act + var result = await _sut.GetValueTypeAsync(dataTypeKey); + + // Assert + Assert.That(result, Is.EqualTo(typeof(string))); + _dataTypeServiceMock.Verify(x => x.GetAsync(dataTypeKey), Times.Once); + } + + [Test] + public async Task GetValueTypeAsync_Returns_Null_When_DataType_Not_Found() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); + + // Act + var result = await _sut.GetValueTypeAsync(dataTypeKey); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public async Task GetValueSchemaAsync_Retrieves_DataType_And_Returns_Schema() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + + // Act + var result = await _sut.GetValueSchemaAsync(dataTypeKey); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result!["type"]?.GetValue(), Is.EqualTo("string")); + } + + [Test] + public async Task GetValueSchemaAsync_Returns_Null_When_DataType_Not_Found() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); + + // Act + var result = await _sut.GetValueSchemaAsync(dataTypeKey); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public async Task ValidateValueAsync_Returns_Empty_When_No_Schema() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, "any value"); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public async Task ValidateValueAsync_Returns_Empty_For_Valid_Value() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, "\"valid string\""); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public async Task ValidateValueAsync_Returns_Errors_For_Invalid_Value() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, "123"); + + // Assert + Assert.That(result, Is.Not.Empty); + Assert.That(result.First().Keyword, Is.EqualTo("type")); + } + + [Test] + public async Task ValidateValueAsync_Handles_JsonNode_Value() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + var jsonValue = JsonValue.Create("valid string"); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public async Task ValidateValueAsync_Returns_Error_For_Invalid_Json_String() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + + // Act - pass a string that's not valid JSON but will be parsed as JSON string literal + var result = await _sut.ValidateValueAsync(dataTypeKey, "{invalid json}"); + + // Assert - it should be treated as a string value which validates against string schema + Assert.That(result, Is.Empty); + } + + private void SetupDataEditors(params IDataEditor[] editors) + { + _dataEditors.Clear(); + _dataEditors.AddRange(editors); + } + + private void SetupDataType(Guid key, string editorAlias, object? configuration = null) + { + var dataType = Mock.Of(dt => + dt.Key == key && + dt.EditorAlias == editorAlias && + dt.ConfigurationObject == configuration); + + _dataTypeServiceMock.Setup(x => x.GetAsync(key)).ReturnsAsync(dataType); + } + + private class MockSchemaProviderEditor : IDataEditor, IValueSchemaProvider + { + public string Alias => "test.schemaProvider"; + public string Name => "Test Schema Provider"; + public string Icon => "icon-test"; + public string? Group => null; + public bool IsDeprecated => false; + public IDictionary? DefaultConfiguration => null; + public IPropertyIndexValueFactory PropertyIndexValueFactory => null!; + + public IDataValueEditor GetValueEditor() => null!; + public IDataValueEditor GetValueEditor(object? configuration) => null!; + public IConfigurationEditor GetConfigurationEditor() => null!; + + public Type? GetValueType(object? configuration) => typeof(string); + + public JsonObject? GetValueSchema(object? configuration) => new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = "string", + }; + } + + private class MockRegularEditor : IDataEditor + { + public string Alias => "test.regular"; + public string Name => "Test Regular"; + public string Icon => "icon-test"; + public string? Group => null; + public bool IsDeprecated => false; + public IDictionary? DefaultConfiguration => null; + public IPropertyIndexValueFactory PropertyIndexValueFactory => null!; + + public IDataValueEditor GetValueEditor() => null!; + public IDataValueEditor GetValueEditor(object? configuration) => null!; + public IConfigurationEditor GetConfigurationEditor() => null!; + } +} From 25972a2454ab18a78ba722cbfa7eaacedb84f7e5 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 5 Feb 2026 13:38:24 +0100 Subject: [PATCH 03/24] Attemp refactor --- .../DataType/DataTypeControllerBase.cs | 22 +++-- .../DataType/SchemaDataTypeController.cs | 15 +-- .../Services/IPropertyEditorSchemaService.cs | 29 ++---- .../PropertyEditorSchemaOperationStatus.cs | 22 +++++ .../Services/PropertyValueSchema.cs | 30 ++++++ .../Implement/PropertyEditorSchemaService.cs | 55 +++++++---- .../PropertyEditorSchemaServiceTests.cs | 96 ++++++++++--------- .../PropertyEditorSchemaServiceTests.cs | 73 ++++++++------ 8 files changed, 214 insertions(+), 128 deletions(-) create mode 100644 src/Umbraco.Core/Services/OperationStatus/PropertyEditorSchemaOperationStatus.cs create mode 100644 src/Umbraco.Core/Services/PropertyValueSchema.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs index 82e8faeb3e21..9ac268c65413 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; @@ -55,15 +55,23 @@ protected IActionResult DataTypeOperationStatusResult(DataTypeOperationStatus st protected IActionResult DataTypeNotFound() => OperationStatusResult(DataTypeOperationStatus.NotFound, DataTypeNotFound); - protected IActionResult SchemaNotFound() => OperationStatusResult(DataTypeOperationStatus.NotFound, SchemaNotFound); + protected IActionResult PropertyEditorSchemaOperationStatusResult(PropertyEditorSchemaOperationStatus status) => + OperationStatusResult(status, problemDetailsBuilder => status switch + { + PropertyEditorSchemaOperationStatus.DataTypeNotFound => NotFound(problemDetailsBuilder + .WithTitle("The data type could not be found") + .Build()), + PropertyEditorSchemaOperationStatus.SchemaNotSupported => NotFound(problemDetailsBuilder + .WithTitle("Schema not supported") + .WithDetail("The property editor for this data type does not support schema information.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown property editor schema operation status.") + .Build()), + }); private IActionResult DataTypeNotFound(ProblemDetailsBuilder problemDetailsBuilder) => NotFound(problemDetailsBuilder .WithTitle("The data type could not be found") .Build()); - - private IActionResult SchemaNotFound(ProblemDetailsBuilder problemDetailsBuilder) - => NotFound(problemDetailsBuilder - .WithTitle("The data type schema definition could not be found") - .Build()); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs index a46bd4bbf488..45f43e08f614 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs @@ -2,7 +2,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.ViewModels.DataType; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.DataType; @@ -36,18 +38,17 @@ public SchemaDataTypeController(IPropertyEditorSchemaService schemaService) [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Schema(Guid id) { - var valueType = await _schemaService.GetValueTypeAsync(id); - var jsonSchema = await _schemaService.GetValueSchemaAsync(id); - - if (valueType is null && jsonSchema is null) + Attempt attempt = await _schemaService.GetSchemaAsync(id); + if (attempt.Success is false) { - return DataTypeNotFound(); + return PropertyEditorSchemaOperationStatusResult(attempt.Status); } + PropertyValueSchema result = attempt.Result; return Ok(new DataTypeSchemaResponseModel { - ValueTypeName = valueType?.FullName, - JsonSchema = jsonSchema, + ValueTypeName = result.ValueType?.FullName, + JsonSchema = result.JsonSchema, }); } } diff --git a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs index 4530d12597b1..13d1fab12f58 100644 --- a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs +++ b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -18,24 +19,14 @@ namespace Umbraco.Cms.Core.Services; public interface IPropertyEditorSchemaService : IService { /// - /// Gets the CLR type for a specific data type's stored values. + /// Gets the complete schema information for a specific data type, including both CLR type and JSON Schema. /// /// The unique key of the data type. /// - /// The CLR type of values stored by this data type, or null if the type cannot be determined - /// or the data type's property editor doesn't implement . + /// An attempt containing the schema information with both CLR type and JSON Schema, + /// or an appropriate operation status if the data type was not found or doesn't support schemas. /// - Task GetValueTypeAsync(Guid dataTypeKey); - - /// - /// Gets the JSON Schema for a specific data type's stored values. - /// - /// The unique key of the data type. - /// - /// A JSON Schema (draft 2020-12) describing the value structure, or null if the schema cannot be generated - /// or the data type's property editor doesn't implement . - /// - Task GetValueSchemaAsync(Guid dataTypeKey); + Task> GetSchemaAsync(Guid dataTypeKey); /// /// Gets the CLR type for a property editor with the specified configuration. @@ -70,12 +61,8 @@ public interface IPropertyEditorSchemaService : IService /// The unique key of the data type. /// The value to validate, as a JSON string or JSON-compatible object. /// - /// A collection of validation results. Returns empty if validation passes, or if the data type - /// doesn't support schema validation. Returns errors if the value doesn't conform to the schema. + /// An attempt containing a collection of validation results (empty if validation passes, or errors if not), + /// or an appropriate operation status if the data type was not found or doesn't support schemas. /// - /// - /// If the data type's property editor doesn't implement or doesn't provide - /// a schema, this method returns an empty collection (validation passes by default). - /// - Task> ValidateValueAsync(Guid dataTypeKey, object? value); + Task, PropertyEditorSchemaOperationStatus>> ValidateValueAsync(Guid dataTypeKey, object? value); } diff --git a/src/Umbraco.Core/Services/OperationStatus/PropertyEditorSchemaOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/PropertyEditorSchemaOperationStatus.cs new file mode 100644 index 000000000000..405e344279d6 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/PropertyEditorSchemaOperationStatus.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +/// +/// Represents the status of a property editor schema operation. +/// +public enum PropertyEditorSchemaOperationStatus +{ + /// + /// The operation completed successfully. + /// + Success, + + /// + /// The specified data type was not found. + /// + DataTypeNotFound, + + /// + /// The property editor does not support schema information (does not implement IValueSchemaProvider). + /// + SchemaNotSupported, +} diff --git a/src/Umbraco.Core/Services/PropertyValueSchema.cs b/src/Umbraco.Core/Services/PropertyValueSchema.cs new file mode 100644 index 000000000000..dc007da88e8a --- /dev/null +++ b/src/Umbraco.Core/Services/PropertyValueSchema.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Represents the schema information for a property editor value. +/// +public sealed class PropertyValueSchema +{ + /// + /// Initializes a new instance of the class. + /// + /// The CLR type of stored values. + /// The JSON Schema describing the value structure. + public PropertyValueSchema(Type? valueType, JsonObject? jsonSchema) + { + ValueType = valueType; + JsonSchema = jsonSchema; + } + + /// + /// Gets the CLR type of stored values, or null if the type cannot be determined. + /// + public Type? ValueType { get; } + + /// + /// Gets the JSON Schema (draft 2020-12) describing the value structure, or null if not provided. + /// + public JsonObject? JsonSchema { get; } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs index 4ebf74d159cd..fe96658ceca2 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -1,8 +1,10 @@ using System.Text.Json; using System.Text.Json.Nodes; using Json.Schema; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Infrastructure.Services.Implement; @@ -26,41 +28,40 @@ public PropertyEditorSchemaService( } /// - public async Task GetValueTypeAsync(Guid dataTypeKey) + public async Task> GetSchemaAsync(Guid dataTypeKey) { var dataType = await _dataTypeService.GetAsync(dataTypeKey); if (dataType is null) { - return null; + return Attempt.FailWithStatus(PropertyEditorSchemaOperationStatus.DataTypeNotFound, new PropertyValueSchema(null, null)); } - return GetValueType(dataType.EditorAlias, dataType.ConfigurationObject); - } - - /// - public async Task GetValueSchemaAsync(Guid dataTypeKey) - { - var dataType = await _dataTypeService.GetAsync(dataTypeKey); - if (dataType is null) + IValueSchemaProvider? provider = GetSchemaProvider(dataType.EditorAlias); + if (provider is null) { - return null; + return Attempt.FailWithStatus(PropertyEditorSchemaOperationStatus.SchemaNotSupported, new PropertyValueSchema(null, null)); } - return GetValueSchema(dataType.EditorAlias, dataType.ConfigurationObject); + Type? valueType = GetValueTypeFromProvider(provider, dataType.ConfigurationObject); + JsonObject? jsonSchema = GetValueSchemaFromProvider(provider, dataType.ConfigurationObject); + + return Attempt.SucceedWithStatus( + PropertyEditorSchemaOperationStatus.Success, + new PropertyValueSchema(valueType, jsonSchema)); } /// public Type? GetValueType(string propertyEditorAlias, object? configuration) { IValueSchemaProvider? provider = GetSchemaProvider(propertyEditorAlias); - return provider?.GetValueType(configuration); + return provider is not null ? GetValueTypeFromProvider(provider, configuration) : null; } /// public JsonObject? GetValueSchema(string propertyEditorAlias, object? configuration) { IValueSchemaProvider? provider = GetSchemaProvider(propertyEditorAlias); - return provider?.GetValueSchema(configuration); + return provider is not null ? GetValueSchemaFromProvider(provider, configuration) : null; } /// @@ -68,13 +69,19 @@ public bool SupportsSchema(string propertyEditorAlias) => GetSchemaProvider(propertyEditorAlias) is not null; /// - public async Task> ValidateValueAsync(Guid dataTypeKey, object? value) + public async Task, PropertyEditorSchemaOperationStatus>> ValidateValueAsync(Guid dataTypeKey, object? value) { - JsonObject? schemaJson = await GetValueSchemaAsync(dataTypeKey); + Attempt schemaAttempt = await GetSchemaAsync(dataTypeKey); + if (schemaAttempt.Success is false) + { + return Attempt.FailWithStatus(schemaAttempt.Status, Enumerable.Empty()); + } + + JsonObject? schemaJson = schemaAttempt.Result.JsonSchema; if (schemaJson is null) { - // No schema available - validation passes by default - return []; + // Schema provider returned null schema - validation passes + return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, Enumerable.Empty()); } try @@ -95,18 +102,24 @@ public async Task> ValidateValueAsync(Guid d if (results.IsValid) { - return []; + return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, Enumerable.Empty()); } // Collect validation errors - return ExtractValidationErrors(results); + return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, ExtractValidationErrors(results).AsEnumerable()); } catch (JsonException ex) { - return [new SchemaValidationResult($"Invalid JSON: {ex.Message}")]; + return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, new[] { new SchemaValidationResult($"Invalid JSON: {ex.Message}") }.AsEnumerable()); } } + private static Type? GetValueTypeFromProvider(IValueSchemaProvider provider, object? configuration) + => provider.GetValueType(configuration); + + private static JsonObject? GetValueSchemaFromProvider(IValueSchemaProvider provider, object? configuration) + => provider.GetValueSchema(configuration); + private static JsonNode? ConvertToJsonNode(object? value) { if (value is null) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs index 0d74baf9f537..d0e4f933d48a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; @@ -49,68 +50,71 @@ public void SupportsSchema_Returns_True_For_ContentPicker_Editor() } [Test] - public async Task GetValueSchemaAsync_Returns_Schema_For_Integer_DataType() + public async Task GetSchemaAsync_Returns_Success_For_Integer_DataType() { // Arrange var dataType = new DataType( new IntegerPropertyEditor(DataValueEditorFactory), ConfigurationEditorJsonSerializer) { - Name = "Test Integer", + Name = "Test Integer GetSchemaAsync", DatabaseType = ValueStorageType.Integer, ConfigurationData = new Dictionary { { "min", 0 }, { "max", 100 }, - { "step", 1 }, }, }; var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); Assert.That(createResult.Success, Is.True); // Act - var schema = await PropertyEditorSchemaService.GetValueSchemaAsync(dataType.Key); + var result = await PropertyEditorSchemaService.GetSchemaAsync(dataType.Key); // Assert - Assert.That(schema, Is.Not.Null); - Assert.That(schema!["$schema"]?.GetValue(), Is.EqualTo("https://json-schema.org/draft/2020-12/schema")); - Assert.That(schema["minimum"]?.GetValue(), Is.EqualTo(0)); - Assert.That(schema["maximum"]?.GetValue(), Is.EqualTo(100)); + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + Assert.That(result.Result.ValueType, Is.EqualTo(typeof(int?))); + Assert.That(result.Result.JsonSchema, Is.Not.Null); + Assert.That(result.Result.JsonSchema!["minimum"]?.GetValue(), Is.EqualTo(0)); + Assert.That(result.Result.JsonSchema["maximum"]?.GetValue(), Is.EqualTo(100)); } [Test] - public async Task GetValueTypeAsync_Returns_Type_For_Integer_DataType() + public async Task GetSchemaAsync_Returns_DataTypeNotFound_For_NonExistent_DataType() { - // Arrange - var dataType = new DataType( - new IntegerPropertyEditor(DataValueEditorFactory), - ConfigurationEditorJsonSerializer) - { - Name = "Test Integer Type", - DatabaseType = ValueStorageType.Integer, - }; - var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); - Assert.That(createResult.Success, Is.True); - // Act - var valueType = await PropertyEditorSchemaService.GetValueTypeAsync(dataType.Key); + var result = await PropertyEditorSchemaService.GetSchemaAsync(Guid.NewGuid()); // Assert - Assert.That(valueType, Is.EqualTo(typeof(int?))); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); } [Test] - public async Task GetValueSchemaAsync_Returns_Null_For_NonExistent_DataType() + public async Task GetSchemaAsync_Returns_SchemaNotSupported_For_Editor_Without_Schema() { + // Arrange - Label editor doesn't implement IValueSchemaProvider + var dataType = new DataType( + new LabelPropertyEditor(DataValueEditorFactory, IOHelper), + ConfigurationEditorJsonSerializer) + { + Name = "Test Label GetSchemaAsync", + DatabaseType = ValueStorageType.Nvarchar, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + // Act - var schema = await PropertyEditorSchemaService.GetValueSchemaAsync(Guid.NewGuid()); + var result = await PropertyEditorSchemaService.GetSchemaAsync(dataType.Key); // Assert - Assert.That(schema, Is.Null); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); } [Test] - public async Task ValidateValueAsync_Returns_Empty_For_Valid_Integer_Value() + public async Task ValidateValueAsync_Returns_Success_Empty_For_Valid_Integer_Value() { // Arrange var dataType = new DataType( @@ -129,14 +133,16 @@ public async Task ValidateValueAsync_Returns_Empty_For_Valid_Integer_Value() Assert.That(createResult.Success, Is.True); // Act - var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "50"); + var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "50"); // Assert - Assert.That(results, Is.Empty); + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + Assert.That(result.Result, Is.Empty); } [Test] - public async Task ValidateValueAsync_Returns_Errors_For_Out_Of_Range_Integer() + public async Task ValidateValueAsync_Returns_Success_With_Errors_For_Out_Of_Range_Integer() { // Arrange var dataType = new DataType( @@ -155,15 +161,16 @@ public async Task ValidateValueAsync_Returns_Errors_For_Out_Of_Range_Integer() Assert.That(createResult.Success, Is.True); // Act - var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "150"); + var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "150"); // Assert - Assert.That(results, Is.Not.Empty); - Assert.That(results.Any(r => r.Keyword == "maximum"), Is.True); + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.Not.Empty); + Assert.That(result.Result.Any(r => r.Keyword == "maximum"), Is.True); } [Test] - public async Task ValidateValueAsync_Returns_Errors_For_Invalid_Type() + public async Task ValidateValueAsync_Returns_Success_With_Errors_For_Invalid_Type() { // Arrange var dataType = new DataType( @@ -177,15 +184,16 @@ public async Task ValidateValueAsync_Returns_Errors_For_Invalid_Type() Assert.That(createResult.Success, Is.True); // Act - var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "\"not an integer\""); + var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "\"not an integer\""); // Assert - Assert.That(results, Is.Not.Empty); - Assert.That(results.Any(r => r.Keyword == "type"), Is.True); + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.Not.Empty); + Assert.That(result.Result.Any(r => r.Keyword == "type"), Is.True); } [Test] - public async Task ValidateValueAsync_Returns_Empty_For_Null_Value_When_Nullable() + public async Task ValidateValueAsync_Returns_Success_Empty_For_Null_Value_When_Nullable() { // Arrange var dataType = new DataType( @@ -199,19 +207,21 @@ public async Task ValidateValueAsync_Returns_Empty_For_Null_Value_When_Nullable( Assert.That(createResult.Success, Is.True); // Act - var results = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "null"); + var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "null"); // Assert - Assert.That(results, Is.Empty); + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.Empty); } [Test] - public async Task ValidateValueAsync_Returns_Empty_For_NonExistent_DataType() + public async Task ValidateValueAsync_Returns_DataTypeNotFound_For_NonExistent_DataType() { // Act - var results = await PropertyEditorSchemaService.ValidateValueAsync(Guid.NewGuid(), "any value"); + var result = await PropertyEditorSchemaService.ValidateValueAsync(Guid.NewGuid(), "any value"); - // Assert - No schema means validation passes by default - Assert.That(results, Is.Empty); + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs index 1b37f072c5dc..e16076d8bf77 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Infrastructure.Services.Implement; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services; @@ -20,7 +21,7 @@ public class PropertyEditorSchemaServiceTests public void SetUp() { _dataTypeServiceMock = new Mock(); - _dataEditors = new List(); + _dataEditors = []; var dataEditorCollection = new DataEditorCollection(() => _dataEditors); _sut = new PropertyEditorSchemaService(_dataTypeServiceMock.Object, dataEditorCollection); } @@ -124,7 +125,7 @@ public void GetValueSchema_Returns_Null_For_NonProvider_Editor() } [Test] - public async Task GetValueTypeAsync_Retrieves_DataType_And_Returns_Type() + public async Task GetSchemaAsync_Returns_Success_With_Both_Type_And_Schema() { // Arrange var dataTypeKey = Guid.NewGuid(); @@ -133,74 +134,82 @@ public async Task GetValueTypeAsync_Retrieves_DataType_And_Returns_Type() SetupDataType(dataTypeKey, "test.schemaProvider"); // Act - var result = await _sut.GetValueTypeAsync(dataTypeKey); + var result = await _sut.GetSchemaAsync(dataTypeKey); // Assert - Assert.That(result, Is.EqualTo(typeof(string))); - _dataTypeServiceMock.Verify(x => x.GetAsync(dataTypeKey), Times.Once); + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + Assert.That(result.Result.ValueType, Is.EqualTo(typeof(string))); + Assert.That(result.Result.JsonSchema, Is.Not.Null); + Assert.That(result.Result.JsonSchema!["type"]?.GetValue(), Is.EqualTo("string")); } [Test] - public async Task GetValueTypeAsync_Returns_Null_When_DataType_Not_Found() + public async Task GetSchemaAsync_Returns_DataTypeNotFound_When_DataType_Not_Found() { // Arrange var dataTypeKey = Guid.NewGuid(); _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); // Act - var result = await _sut.GetValueTypeAsync(dataTypeKey); + var result = await _sut.GetSchemaAsync(dataTypeKey); // Assert - Assert.That(result, Is.Null); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); } [Test] - public async Task GetValueSchemaAsync_Retrieves_DataType_And_Returns_Schema() + public async Task GetSchemaAsync_Returns_SchemaNotSupported_When_Editor_Does_Not_Support_Schema() { // Arrange var dataTypeKey = Guid.NewGuid(); - var schemaProviderEditor = new MockSchemaProviderEditor(); - SetupDataEditors(schemaProviderEditor); - SetupDataType(dataTypeKey, "test.schemaProvider"); + var regularEditor = new MockRegularEditor(); + SetupDataEditors(regularEditor); + SetupDataType(dataTypeKey, "test.regular"); // Act - var result = await _sut.GetValueSchemaAsync(dataTypeKey); + var result = await _sut.GetSchemaAsync(dataTypeKey); // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result!["type"]?.GetValue(), Is.EqualTo("string")); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); } [Test] - public async Task GetValueSchemaAsync_Returns_Null_When_DataType_Not_Found() + public async Task ValidateValueAsync_Returns_DataTypeNotFound_When_DataType_Not_Found() { // Arrange var dataTypeKey = Guid.NewGuid(); _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); // Act - var result = await _sut.GetValueSchemaAsync(dataTypeKey); + var result = await _sut.ValidateValueAsync(dataTypeKey, "any value"); // Assert - Assert.That(result, Is.Null); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); } [Test] - public async Task ValidateValueAsync_Returns_Empty_When_No_Schema() + public async Task ValidateValueAsync_Returns_SchemaNotSupported_When_Editor_Does_Not_Support_Schema() { // Arrange var dataTypeKey = Guid.NewGuid(); - _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); + var regularEditor = new MockRegularEditor(); + SetupDataEditors(regularEditor); + SetupDataType(dataTypeKey, "test.regular"); // Act var result = await _sut.ValidateValueAsync(dataTypeKey, "any value"); // Assert - Assert.That(result, Is.Empty); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); } [Test] - public async Task ValidateValueAsync_Returns_Empty_For_Valid_Value() + public async Task ValidateValueAsync_Returns_Success_Empty_For_Valid_Value() { // Arrange var dataTypeKey = Guid.NewGuid(); @@ -212,11 +221,13 @@ public async Task ValidateValueAsync_Returns_Empty_For_Valid_Value() var result = await _sut.ValidateValueAsync(dataTypeKey, "\"valid string\""); // Assert - Assert.That(result, Is.Empty); + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + Assert.That(result.Result, Is.Empty); } [Test] - public async Task ValidateValueAsync_Returns_Errors_For_Invalid_Value() + public async Task ValidateValueAsync_Returns_Success_With_Errors_For_Invalid_Value() { // Arrange var dataTypeKey = Guid.NewGuid(); @@ -228,8 +239,10 @@ public async Task ValidateValueAsync_Returns_Errors_For_Invalid_Value() var result = await _sut.ValidateValueAsync(dataTypeKey, "123"); // Assert - Assert.That(result, Is.Not.Empty); - Assert.That(result.First().Keyword, Is.EqualTo("type")); + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + Assert.That(result.Result, Is.Not.Empty); + Assert.That(result.Result.First().Keyword, Is.EqualTo("type")); } [Test] @@ -246,11 +259,12 @@ public async Task ValidateValueAsync_Handles_JsonNode_Value() var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); // Assert - Assert.That(result, Is.Empty); + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.Empty); } [Test] - public async Task ValidateValueAsync_Returns_Error_For_Invalid_Json_String() + public async Task ValidateValueAsync_Returns_Success_For_Invalid_Json_String() { // Arrange var dataTypeKey = Guid.NewGuid(); @@ -262,7 +276,8 @@ public async Task ValidateValueAsync_Returns_Error_For_Invalid_Json_String() var result = await _sut.ValidateValueAsync(dataTypeKey, "{invalid json}"); // Assert - it should be treated as a string value which validates against string schema - Assert.That(result, Is.Empty); + Assert.That(result.Success, Is.True); + Assert.That(result.Result, Is.Empty); } private void SetupDataEditors(params IDataEditor[] editors) From 1a7b74559be2c4e7f9a18fd159120c1b0084ea4c Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 10 Feb 2026 10:27:34 +0100 Subject: [PATCH 04/24] Fix json single parent bug --- .../PropertyEditors/BlockGridPropertyEditorBase.cs | 2 +- .../PropertyEditors/BlockListPropertyEditorBase.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index fa63a881b19b..04621feb371d 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -142,7 +142,7 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["settingsData"] = new JsonObject { ["type"] = "array", - ["items"] = blockItemDataSchema, + ["items"] = blockItemDataSchema.DeepClone(), }, }, }; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index afa42c064830..7a5db7c867e0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -146,7 +146,7 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["settingsData"] = new JsonObject { ["type"] = "array", - ["items"] = blockItemDataSchema, + ["items"] = blockItemDataSchema.DeepClone(), }, ["expose"] = new JsonObject { From a68d3925bb3124ab1f78bff9f915d5f8fd598299 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 10 Feb 2026 11:02:14 +0100 Subject: [PATCH 05/24] Surface doctype schema validation to management api --- .../ValidateSchemaDataTypeController.cs | 60 +++++++++++++++++++ .../SchemaValidationResultResponseModel.cs | 22 +++++++ .../ValidateDataTypeValueRequestModel.cs | 12 ++++ 3 files changed, 94 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs new file mode 100644 index 000000000000..dad765ffc246 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs @@ -0,0 +1,60 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.DataType; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.DataType; + +/// +/// Controller for validating values against data type schemas. +/// +[ApiVersion("1.0")] +public class ValidateSchemaDataTypeController : DataTypeControllerBase +{ + private readonly IPropertyEditorSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + /// The property editor schema service. + public ValidateSchemaDataTypeController(IPropertyEditorSchemaService schemaService) + => _schemaService = schemaService; + + /// + /// Validates a value against the data type's JSON Schema. + /// + /// The unique identifier of the data type. + /// The request containing the value to validate. + /// A collection of validation errors (empty if validation passes). + /// + /// Returns validation results for property editors that implement IValueSchemaProvider. + /// Returns 404 if the data type is not found or doesn't support schema information. + /// + [HttpPost("{id:guid}/schema/validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ValidateValue(Guid id, ValidateDataTypeValueRequestModel requestModel) + { + Attempt, PropertyEditorSchemaOperationStatus> attempt = + await _schemaService.ValidateValueAsync(id, requestModel.Value); + + if (attempt.Success is false) + { + return PropertyEditorSchemaOperationStatusResult(attempt.Status); + } + + IEnumerable results = attempt.Result + .Select(r => new SchemaValidationResultResponseModel + { + Message = r.Message, + Path = r.Path, + Keyword = r.Keyword, + }); + + return Ok(results); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs new file mode 100644 index 000000000000..f458992be710 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.DataType; + +/// +/// Represents a single validation error from schema validation. +/// +public class SchemaValidationResultResponseModel +{ + /// + /// Gets or sets the validation error message. + /// + public required string Message { get; set; } + + /// + /// Gets or sets the JSON path where the error occurred (e.g., "$.items[0].name"). + /// + public string? Path { get; set; } + + /// + /// Gets or sets the JSON Schema keyword that failed (e.g., "type", "required", "minimum"). + /// + public string? Keyword { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs new file mode 100644 index 000000000000..abbf8b1fed7e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs @@ -0,0 +1,12 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.DataType; + +/// +/// Request model for validating a value against a data type's schema. +/// +public class ValidateDataTypeValueRequestModel +{ + /// + /// The value to validate. Can be any JSON-compatible value (object, array, string, number, boolean, or null). + /// + public object? Value { get; set; } +} From a520af890c06faeb0e6e793cde2bc34614fbfd0e Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 10 Feb 2026 11:53:50 +0100 Subject: [PATCH 06/24] Improve block schema and make validation errors less verbose --- .../BlockGridPropertyEditorBase.cs | 13 +--- .../BlockListPropertyEditorBase.cs | 21 ++---- .../Implement/PropertyEditorSchemaService.cs | 70 ++++++++++++++++--- 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index 04621feb371d..4440007f541c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -62,18 +62,11 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac var layoutItemSchema = new JsonObject { ["type"] = "object", - ["required"] = new JsonArray("contentUdi"), + ["required"] = new JsonArray("contentKey"), ["properties"] = new JsonObject { - ["contentUdi"] = new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, - ["settingsUdi"] = new JsonObject - { - ["oneOf"] = new JsonArray - { - new JsonObject { ["type"] = "null" }, - new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, - }, - }, + ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid" }, ["columnSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, ["rowSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, ["areas"] = new JsonObject diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index 7a5db7c867e0..fbf0495cba73 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -49,18 +49,11 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac var layoutItemSchema = new JsonObject { ["type"] = "object", - ["required"] = new JsonArray("contentUdi"), + ["required"] = new JsonArray("contentKey"), ["properties"] = new JsonObject { - ["contentUdi"] = new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, - ["settingsUdi"] = new JsonObject - { - ["oneOf"] = new JsonArray - { - new JsonObject { ["type"] = "null" }, - new JsonObject { ["type"] = "string", ["pattern"] = "^umb:\\/\\/element\\/[a-f0-9-]+$" }, - }, - }, + ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid" }, }, }; @@ -91,15 +84,15 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac }, }; - // Build expose schema + // Build expose schema (BlockItemVariation) var exposeItemSchema = new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, - ["cultures"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } }, - ["segments"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = new JsonArray("string", "null") } }, + ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, }, }; diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs index fe96658ceca2..5a16162ed88d 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -153,10 +153,62 @@ public async Task, PropertyEditorSch private static List ExtractValidationErrors(EvaluationResults results) { var errors = new List(); + CollectValidationErrors(results, errors); + // JSON Schema validators report the same logical error multiple times through different + // schema evaluation paths. Deduplicate while collecting paths in a single pass. + var seen = new HashSet<(string, string?, string?)>(); + var allPaths = new HashSet(); + errors.RemoveAll(e => + { + var isDuplicate = !seen.Add((e.Message, e.Path, e.Keyword)); + if (!isDuplicate && e.Path is not null) + { + allPaths.Add(e.Path); + } + + return isDuplicate; + }); + + // When all errors lack paths, there's nothing to filter + if (allPaths.Count == 0) + { + return errors; + } + + // Schema validators bubble up failures to parent paths with generic "does not conform" + // messages. These add noise when we already have the specific child error. Identify + // which paths are parents of other paths so we can filter out their generic errors. + var parentPathsWithChildren = new HashSet(); + foreach (var path in allPaths) + { + var lastSlash = path.LastIndexOf('/'); + while (lastSlash > 0) + { + var parentPath = path[..lastSlash]; + if (allPaths.Contains(parentPath)) + { + parentPathsWithChildren.Add(parentPath); + } + + lastSlash = parentPath.LastIndexOf('/'); + } + } + + // When path-specific errors exist, pathless errors provide no actionable information. + // Similarly, generic parent errors (keyword=null) are noise when child errors pinpoint the issue. + errors.RemoveAll(e => + string.IsNullOrEmpty(e.Path) || + (e.Keyword is null && parentPathsWithChildren.Contains(e.Path))); + + return errors; + } + + private static void CollectValidationErrors(EvaluationResults results, List errors) + { if (results.Details is null || results.Details.Count == 0) { - // No details, create a generic error from the top-level result + // No details, create an error from the current result if (!results.IsValid && results.Errors is not null) { foreach (var error in results.Errors) @@ -169,10 +221,12 @@ private static List ExtractValidationErrors(EvaluationRe } else if (!results.IsValid) { - errors.Add(new SchemaValidationResult("Value does not conform to schema")); + errors.Add(new SchemaValidationResult( + "Value does not conform to schema", + results.InstanceLocation?.ToString())); } - return errors; + return; } // Process nested results @@ -193,14 +247,10 @@ private static List ExtractValidationErrors(EvaluationRe error.Key)); } } - else - { - // Recursively check nested details - errors.AddRange(ExtractValidationErrors(detail)); - } - } - return errors; + // Always recurse to find more specific errors + CollectValidationErrors(detail, errors); + } } private IValueSchemaProvider? GetSchemaProvider(string propertyEditorAlias) From f64740327ffd1b48899d603f4d135a17d3556537 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 10 Feb 2026 18:06:15 +0100 Subject: [PATCH 07/24] fix validation error cleanup --- .../Implement/PropertyEditorSchemaService.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs index 5a16162ed88d..ac153f2ce1d4 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -195,11 +195,15 @@ private static List ExtractValidationErrors(EvaluationRe } } - // When path-specific errors exist, pathless errors provide no actionable information. - // Similarly, generic parent errors (keyword=null) are noise when child errors pinpoint the issue. + // Determine if there are errors at deeper levels (non-root paths). Root path is "" in JSON Pointer. + var hasDeepErrors = allPaths.Any(p => p.Length > 0); + + // Only filter pathless errors (Path is null) when more specific path errors exist. + // Don't filter empty paths ("") as this represents the root location which is valid. + // Similarly, filter generic parent errors (keyword=null) only when specific child errors exist. errors.RemoveAll(e => - string.IsNullOrEmpty(e.Path) || - (e.Keyword is null && parentPathsWithChildren.Contains(e.Path))); + (e.Path is null && hasDeepErrors) || + (e.Keyword is null && parentPathsWithChildren.Contains(e.Path ?? string.Empty))); return errors; } From d6fd40d9af2416d733748fa0ceac431b1ef97bdb Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 12 Feb 2026 14:02:30 +0100 Subject: [PATCH 08/24] Improved GUID handling | added schema for all propertyEditors --- .../ContentPickerPropertyEditor.cs | 5 +- .../PropertyEditors/DecimalPropertyEditor.cs | 37 +++++++++- .../EyeDropperColorPickerPropertyEditor.cs | 16 +++- .../PropertyEditors/IValueSchemaProvider.cs | 21 +++--- .../PropertyEditors/LabelPropertyEditor.cs | 14 +++- .../PropertyEditors/MarkdownPropertyEditor.cs | 14 +++- .../MemberGroupPickerPropertyEditor.cs | 14 +++- .../MemberPickerPropertyEditor.cs | 16 +++- .../PlainDateTimePropertyEditor.cs | 18 ++++- .../PlainDecimalPropertyEditor.cs | 17 ++++- .../PlainIntegerPropertyEditor.cs | 19 ++++- .../PlainJsonPropertyEditor.cs | 16 +++- .../PlainStringPropertyEditor.cs | 17 ++++- .../PlainTimePropertyEditor.cs | 16 +++- .../UserPickerPropertyEditor.cs | 16 +++- .../PropertyEditors/ValueSchemaPatterns.cs | 16 ++++ .../BlockGridPropertyEditorBase.cs | 10 +-- .../BlockListPropertyEditorBase.cs | 10 +-- .../CheckBoxListPropertyEditor.cs | 18 ++++- .../ColorPickerPropertyEditor.cs | 26 ++++++- .../PropertyEditors/DateTimePropertyEditor.cs | 15 +++- .../DateTimePropertyEditorBase.cs | 27 ++++++- .../DropDownFlexiblePropertyEditor.cs | 18 ++++- .../EmailAddressPropertyEditor.cs | 15 +++- .../FileUploadPropertyEditor.cs | 30 +++++++- .../ImageCropperPropertyEditor.cs | 64 ++++++++++++++++ .../MediaPicker3PropertyEditor.cs | 4 +- .../MultiNodeTreePickerPropertyEditor.cs | 53 ++++++++++++- .../MultiUrlPickerPropertyEditor.cs | 74 ++++++++++++++++++- .../MultipleTextStringPropertyEditor.cs | 37 +++++++++- .../RadioButtonsPropertyEditor.cs | 15 +++- .../PropertyEditors/RichTextPropertyEditor.cs | 28 ++++++- .../PropertyEditors/SliderPropertyEditor.cs | 51 ++++++++++++- .../PropertyEditors/TagsPropertyEditor.cs | 18 ++++- .../PropertyEditors/TextboxPropertyEditor.cs | 32 +++++++- .../TrueFalsePropertyEditor.cs | 7 +- 36 files changed, 761 insertions(+), 63 deletions(-) create mode 100644 src/Umbraco.Core/PropertyEditors/ValueSchemaPatterns.cs diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs index 6ae1b42c8d83..0c98f5643e62 100644 --- a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs @@ -45,8 +45,9 @@ public ContentPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactor { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["type"] = new JsonArray("string", "null"), - ["pattern"] = "^umb:\\/\\/document\\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", - ["description"] = "UDI reference to a document (e.g., umb://document/a1b2c3d4-e5f6-7890-1234-567890abcdef)", + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "GUID of the selected document", }; /// diff --git a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs index 9be861bff6f7..378264387023 100644 --- a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -20,7 +21,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.Decimal, ValueType = ValueTypes.Decimal, ValueEditorIsReusable = true)] -public class DecimalPropertyEditor : DataEditor +public class DecimalPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -30,6 +31,40 @@ public DecimalPropertyEditor( : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(decimal?); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("number", "null"), + }; + + // Add min/max/step constraints from configuration if available + if (configuration is IDictionary configDict) + { + if (configDict.TryGetValue("min", out var minValue) && minValue is double min) + { + schema["minimum"] = min; + } + + if (configDict.TryGetValue("max", out var maxValue) && maxValue is double max) + { + schema["maximum"] = max; + } + + if (configDict.TryGetValue("step", out var stepValue) && stepValue is double step && step > 0) + { + schema["multipleOf"] = step; + } + } + + return schema; + } + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Core/PropertyEditors/EyeDropperColorPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/EyeDropperColorPickerPropertyEditor.cs index 39633c1a509d..98168a6a0cc0 100644 --- a/src/Umbraco.Core/PropertyEditors/EyeDropperColorPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/EyeDropperColorPickerPropertyEditor.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; + namespace Umbraco.Cms.Core.PropertyEditors; /// @@ -6,7 +8,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.ColorPickerEyeDropper, ValueEditorIsReusable = true)] -public class EyeDropperColorPickerPropertyEditor : DataEditor +public class EyeDropperColorPickerPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -15,4 +17,16 @@ public class EyeDropperColorPickerPropertyEditor : DataEditor public EyeDropperColorPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["pattern"] = "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$", + ["description"] = "Hex color value (e.g., #FF0000 or #FF0000FF with alpha)", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs b/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs index d82f27480179..7f4569e2c591 100644 --- a/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs +++ b/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs @@ -3,7 +3,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// -/// Provides schema information about the values a property editor accepts and stores. +/// Provides schema information about the values a property editor accepts. /// /// /// @@ -11,32 +11,33 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// for programmatic content creation, validation, and tooling support. /// /// -/// Implementations should return the schema for the database/stored model (what -/// produces), not the editor model (what produces) or the published model -/// (what produces). +/// Implementations should return the schema for the incoming value (what +/// receives via ), not the stored/database model (what +/// produces) or the published model (what +/// produces). /// /// public interface IValueSchemaProvider { /// - /// Gets the CLR type that represents the stored value structure. + /// Gets the CLR type that represents the incoming value structure. /// /// The data type configuration, which may affect the value type. /// - /// The CLR type of the stored value, or null if the type cannot be determined + /// The CLR type of the incoming value, or null if the type cannot be determined /// or varies significantly based on configuration. /// /// /// /// For simple editors (e.g., textbox), this might return . - /// For complex editors (e.g., MediaPicker3), this returns the DTO type. + /// For complex editors (e.g., MediaPicker3), this returns the DTO type that the editor submits. /// For block-based editors where the structure is entirely configuration-dependent, this may return null. /// /// Type? GetValueType(object? configuration); /// - /// Gets a JSON Schema (draft 2020-12) describing the value structure. + /// Gets a JSON Schema (draft 2020-12) describing the incoming value structure. /// /// The data type configuration, which may affect the schema. /// @@ -45,8 +46,8 @@ public interface IValueSchemaProvider /// /// /// - /// The returned schema should describe the structure that produces - /// and that can be passed to when creating content programmatically. + /// The returned schema should describe the structure that receives + /// (i.e., what the Management API accepts). /// /// /// For configuration-dependent schemas (e.g., BlockList with specific element types), the schema diff --git a/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs index dbc74a718da4..52ee0112a173 100644 --- a/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/LabelPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Serialization; @@ -14,7 +15,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.Label, ValueEditorIsReusable = true)] -public class LabelPropertyEditor : DataEditor +public class LabelPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -28,6 +29,17 @@ public LabelPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHe SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["description"] = "Read-only value, any value provided will be ignored", + }; + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Core/PropertyEditors/MarkdownPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/MarkdownPropertyEditor.cs index 39693f3c3470..51a78bb6d31a 100644 --- a/src/Umbraco.Core/PropertyEditors/MarkdownPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/MarkdownPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Core.PropertyEditors; @@ -12,7 +13,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MarkdownEditor, ValueType = ValueTypes.Text, ValueEditorIsReusable = true)] -public class MarkdownPropertyEditor : DataEditor +public class MarkdownPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -21,6 +22,17 @@ public MarkdownPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["description"] = "Markdown formatted text", + }; + /// /// Create a custom value editor /// diff --git a/src/Umbraco.Core/PropertyEditors/MemberGroupPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/MemberGroupPickerPropertyEditor.cs index ff26712a4597..c83a3c530625 100644 --- a/src/Umbraco.Core/PropertyEditors/MemberGroupPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/MemberGroupPickerPropertyEditor.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -15,7 +16,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MemberGroupPicker, ValueType = ValueTypes.Text, ValueEditorIsReusable = true)] -public class MemberGroupPickerPropertyEditor : DataEditor +public class MemberGroupPickerPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -25,6 +26,17 @@ public MemberGroupPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFa : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["description"] = "Comma-separated list of member group GUIDs", + }; + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Core/PropertyEditors/MemberPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/MemberPickerPropertyEditor.cs index 7dd3ee2ada01..0a4b38d2e443 100644 --- a/src/Umbraco.Core/PropertyEditors/MemberPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/MemberPickerPropertyEditor.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -15,7 +16,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MemberPicker, ValueType = ValueTypes.String, ValueEditorIsReusable = true)] -public class MemberPickerPropertyEditor : DataEditor +public class MemberPickerPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -25,6 +26,19 @@ public MemberPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(Guid?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "GUID of the selected member", + }; + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Core/PropertyEditors/PlainDateTimePropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/PlainDateTimePropertyEditor.cs index 737f5b4db20e..fea6f8fffcd7 100644 --- a/src/Umbraco.Core/PropertyEditors/PlainDateTimePropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/PlainDateTimePropertyEditor.cs @@ -1,6 +1,8 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; + namespace Umbraco.Cms.Core.PropertyEditors; /// @@ -10,7 +12,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.PlainDateTime, ValueEditorIsReusable = true, ValueType = ValueTypes.DateTime)] -public class PlainDateTimePropertyEditor : DataEditor +public class PlainDateTimePropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -18,4 +20,16 @@ public class PlainDateTimePropertyEditor : DataEditor public PlainDateTimePropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(DateTime?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["format"] = "date-time", + ["description"] = "ISO 8601 date-time string", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/PlainDecimalPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/PlainDecimalPropertyEditor.cs index 89b7442662c4..b48652de9741 100644 --- a/src/Umbraco.Core/PropertyEditors/PlainDecimalPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/PlainDecimalPropertyEditor.cs @@ -1,4 +1,6 @@ -namespace Umbraco.Cms.Core.PropertyEditors; +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors; /// /// Represents a property editor for configuration-less decimal properties. @@ -7,7 +9,7 @@ Constants.PropertyEditors.Aliases.PlainDecimal, ValueEditorIsReusable = true, ValueType = ValueTypes.Decimal)] -public class PlainDecimalPropertyEditor : DataEditor +public class PlainDecimalPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -15,4 +17,15 @@ public class PlainDecimalPropertyEditor : DataEditor public PlainDecimalPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(decimal?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("number", "null"), + ["description"] = "Plain decimal number value", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/PlainIntegerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/PlainIntegerPropertyEditor.cs index 3cedbd19fb5e..7e98c1b941e3 100644 --- a/src/Umbraco.Core/PropertyEditors/PlainIntegerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/PlainIntegerPropertyEditor.cs @@ -1,13 +1,15 @@ -namespace Umbraco.Cms.Core.PropertyEditors; +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors; /// -/// Represents a property editor for configuration-less decimal properties. +/// Represents a property editor for configuration-less integer properties. /// [DataEditor( Constants.PropertyEditors.Aliases.PlainInteger, ValueEditorIsReusable = true, ValueType = ValueTypes.Integer)] -public class PlainIntegerPropertyEditor : DataEditor +public class PlainIntegerPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -15,4 +17,15 @@ public class PlainIntegerPropertyEditor : DataEditor public PlainIntegerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(int?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("integer", "null"), + ["description"] = "Plain integer value", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/PlainJsonPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/PlainJsonPropertyEditor.cs index 040dd74023c2..08f5b648e008 100644 --- a/src/Umbraco.Core/PropertyEditors/PlainJsonPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/PlainJsonPropertyEditor.cs @@ -1,4 +1,6 @@ -namespace Umbraco.Cms.Core.PropertyEditors; +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors; /// /// Represents a property editor for configuration-less JSON properties. @@ -7,7 +9,7 @@ Constants.PropertyEditors.Aliases.PlainJson, ValueEditorIsReusable = true, ValueType = ValueTypes.Json)] -public class PlainJsonPropertyEditor : DataEditor +public class PlainJsonPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -15,4 +17,14 @@ public class PlainJsonPropertyEditor : DataEditor public PlainJsonPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(object); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["description"] = "Any valid JSON value", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/PlainStringPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/PlainStringPropertyEditor.cs index 16986b1931ad..ba3962d4ca3f 100644 --- a/src/Umbraco.Core/PropertyEditors/PlainStringPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/PlainStringPropertyEditor.cs @@ -1,4 +1,6 @@ -namespace Umbraco.Cms.Core.PropertyEditors; +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors; /// /// Represents a property editor for configuration-less string properties. @@ -7,7 +9,7 @@ Constants.PropertyEditors.Aliases.PlainString, ValueEditorIsReusable = true, ValueType = ValueTypes.Text)] // NOTE: for ease of use it's called "String", but it's really stored as TEXT -public class PlainStringPropertyEditor : DataEditor +public class PlainStringPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -15,4 +17,15 @@ public class PlainStringPropertyEditor : DataEditor public PlainStringPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["description"] = "Plain text string value", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/PlainTimePropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/PlainTimePropertyEditor.cs index 4af7c1d6432d..30fb90b7af7b 100644 --- a/src/Umbraco.Core/PropertyEditors/PlainTimePropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/PlainTimePropertyEditor.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; + namespace Umbraco.Cms.Core.PropertyEditors; /// @@ -7,7 +9,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.PlainTime, ValueEditorIsReusable = true, ValueType = ValueTypes.Time)] -public class PlainTimePropertyEditor : DataEditor +public class PlainTimePropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -16,4 +18,16 @@ public class PlainTimePropertyEditor : DataEditor public PlainTimePropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + + /// + public Type? GetValueType(object? configuration) => typeof(TimeOnly?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["format"] = "time", + ["description"] = "ISO 8601 time string", + }; } diff --git a/src/Umbraco.Core/PropertyEditors/UserPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/UserPickerPropertyEditor.cs index 9e3c27e766b4..4a6fd84fd3a2 100644 --- a/src/Umbraco.Core/PropertyEditors/UserPickerPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/UserPickerPropertyEditor.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -15,7 +16,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.UserPicker, ValueType = ValueTypes.Integer, ValueEditorIsReusable = true)] -public class UserPickerPropertyEditor : DataEditor +public class UserPickerPropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -25,6 +26,19 @@ public UserPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(Guid?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "GUID of the selected user", + }; + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Core/PropertyEditors/ValueSchemaPatterns.cs b/src/Umbraco.Core/PropertyEditors/ValueSchemaPatterns.cs new file mode 100644 index 000000000000..0bcd2bc9c1a1 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ValueSchemaPatterns.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Contains regex patterns used in JSON Schema validation for property editor values. +/// +public static class ValueSchemaPatterns +{ + /// + /// Regex pattern for validating UUID/GUID strings. + /// + /// + /// Matches UUIDs with or without hyphens, case-insensitive (lowercase in pattern). + /// Examples: "550e8400-e29b-41d4-a716-446655440000" or "550e8400e29b41d4a716446655440000" + /// + public const string Uuid = "^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$"; +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index 4440007f541c..def5f2850759 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -49,7 +49,7 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["type"] = "object", ["properties"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, ["items"] = new JsonObject { ["type"] = "array", @@ -65,8 +65,8 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["required"] = new JsonArray("contentKey"), ["properties"] = new JsonObject { - ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, - ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid" }, + ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, ["columnSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, ["rowSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, ["areas"] = new JsonObject @@ -84,8 +84,8 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["required"] = new JsonArray("key", "contentTypeKey"), ["properties"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, - ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, ["values"] = new JsonObject { ["type"] = "array", diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index fbf0495cba73..15b872822f24 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -52,8 +52,8 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["required"] = new JsonArray("contentKey"), ["properties"] = new JsonObject { - ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, - ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid" }, + ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, }, }; @@ -64,8 +64,8 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["required"] = new JsonArray("key", "contentTypeKey"), ["properties"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, - ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, ["values"] = new JsonObject { ["type"] = "array", @@ -90,7 +90,7 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["type"] = "object", ["properties"] = new JsonObject { - ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, }, diff --git a/src/Umbraco.Infrastructure/PropertyEditors/CheckBoxListPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/CheckBoxListPropertyEditor.cs index 9f9606e3f019..e96a5f6487d1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/CheckBoxListPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/CheckBoxListPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Serialization; @@ -14,7 +15,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.CheckBoxList, ValueType = ValueTypes.Text, // We use the Text value type to ensure we don't run out of storage space in the database field with large lists with multiple values selected. ValueEditorIsReusable = true)] -public class CheckBoxListPropertyEditor : DataEditor +public class CheckBoxListPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; @@ -30,6 +31,21 @@ public CheckBoxListPropertyEditor(IDataValueEditorFactory dataValueEditorFactory SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(IEnumerable); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "string", + }, + ["description"] = "Array of selected values", + }; + /// protected override IConfigurationEditor CreateConfigurationEditor() => new ValueListConfigurationEditor(_ioHelper, _configurationEditorJsonSerializer); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs index 15b53e691ef5..6379a036adee 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerPropertyEditor.cs @@ -19,7 +19,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.ColorPicker, ValueEditorIsReusable = true)] -public class ColorPickerPropertyEditor : DataEditor +public class ColorPickerPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; @@ -38,6 +38,30 @@ public ColorPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, /// public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + /// + public Type? GetValueType(object? configuration) => typeof(JsonObject); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["value"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Color value (hex format, e.g., '#ff0000')", + }, + ["label"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Display label for the color", + }, + }, + ["description"] = "Color picker value with hex color and optional label", + }; + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs index 50d06b725bf1..2a6f5708195b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditor.cs @@ -4,6 +4,7 @@ using System.Data.SqlTypes; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; @@ -21,7 +22,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.DateTime, ValueType = ValueTypes.DateTime, ValueEditorIsReusable = true)] -public class DateTimePropertyEditor : DataEditor +public class DateTimePropertyEditor : DataEditor, IValueSchemaProvider { /// /// Initializes a new instance of the class. @@ -30,6 +31,18 @@ public DateTimePropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(DateTime?); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["format"] = "date-time", + ["description"] = "ISO 8601 date-time string", + }; + /// protected override IDataValueEditor CreateValueEditor() { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs index 4db36db3872c..481477e3efd6 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorBase.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -21,7 +22,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// /// Provides base functionality for date time property editors that store their value as a JSON string with timezone information. /// -public abstract class DateTimePropertyEditorBase : DataEditor +public abstract class DateTimePropertyEditorBase : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; private readonly IPropertyIndexValueFactory _propertyIndexValueFactory; @@ -40,6 +41,30 @@ protected DateTimePropertyEditorBase( SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(DateTimeEditorValue); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["date"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "ISO 8601 date-time string", + }, + ["timeZone"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "IANA timezone identifier (e.g., 'Europe/London')", + }, + }, + ["description"] = "Date/time value with optional timezone", + }; + /// protected override IConfigurationEditor CreateConfigurationEditor() => new DateTimeConfigurationEditor(_ioHelper); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/DropDownFlexiblePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/DropDownFlexiblePropertyEditor.cs index 9b5886f68592..df5591817b44 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/DropDownFlexiblePropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/DropDownFlexiblePropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Serialization; @@ -10,7 +11,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.DropDownListFlexible, ValueEditorIsReusable = true)] -public class DropDownFlexiblePropertyEditor : DataEditor +public class DropDownFlexiblePropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; @@ -23,6 +24,21 @@ public DropDownFlexiblePropertyEditor(IDataValueEditorFactory dataValueEditorFac SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(IEnumerable); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "string", + }, + ["description"] = "Array of selected values from dropdown", + }; + protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs index 164bc6fd8545..bccf391bc036 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors.Validators; using Umbraco.Cms.Core.Services; @@ -13,7 +14,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.EmailAddress, ValueEditorIsReusable = true)] -public class EmailAddressPropertyEditor : DataEditor +public class EmailAddressPropertyEditor : DataEditor, IValueSchemaProvider { private readonly ILocalizedTextService _localizedTextService; @@ -27,6 +28,18 @@ public EmailAddressPropertyEditor(IDataValueEditorFactory dataValueEditorFactory _localizedTextService = localizedTextService; } + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["format"] = "email", + ["description"] = "Email address", + }; + /// protected override IDataValueEditor CreateValueEditor() { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs index 6dd536f32fb3..0b760aad3e92 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs @@ -2,8 +2,10 @@ // See LICENSE for more details. using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; namespace Umbraco.Cms.Core.PropertyEditors; @@ -13,7 +15,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.UploadField, ValueEditorIsReusable = true)] -public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator +public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -29,6 +31,32 @@ public FileUploadPropertyEditor( SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(FileUploadValue); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["src"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Source file path", + }, + ["temporaryFileId"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "Temporary file ID for new uploads", + }, + }, + ["description"] = "File upload value with source path or temporary file reference", + }; + /// public bool TryGetMediaPath(string? propertyEditorAlias, object? value, [MaybeNullWhen(false)] out string mediaPath) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs index c432486721de..b66589759219 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; @@ -26,6 +27,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; ValueEditorIsReusable = true)] public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator, + IValueSchemaProvider, INotificationHandler, INotificationHandler, INotificationHandler, @@ -72,6 +74,68 @@ public ImageCropperPropertyEditor( public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + /// + public Type? GetValueType(object? configuration) => typeof(ImageCropperValue); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["src"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Source image path", + }, + ["temporaryFileId"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "Temporary file ID for new uploads", + }, + ["focalPoint"] = new JsonObject + { + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["left"] = new JsonObject { ["type"] = "number" }, + ["top"] = new JsonObject { ["type"] = "number" }, + }, + ["description"] = "Focal point coordinates (0-1 range)", + }, + ["crops"] = new JsonObject + { + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["width"] = new JsonObject { ["type"] = "integer" }, + ["height"] = new JsonObject { ["type"] = "integer" }, + ["coordinates"] = new JsonObject + { + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["x1"] = new JsonObject { ["type"] = "number" }, + ["y1"] = new JsonObject { ["type"] = "number" }, + ["x2"] = new JsonObject { ["type"] = "number" }, + ["y2"] = new JsonObject { ["type"] = "number" }, + }, + }, + }, + }, + ["description"] = "Image crop definitions", + }, + }, + ["description"] = "Image cropper value with source, focal point, and crop definitions", + }; + public bool TryGetMediaPath(string? propertyEditorAlias, object? value, out string? mediaPath) { if (propertyEditorAlias == Alias && diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index efd8c02563ec..ceaaefd2d215 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -57,8 +57,8 @@ public MediaPicker3PropertyEditor(IDataValueEditorFactory dataValueEditorFactory ["required"] = new JsonArray("key", "mediaKey"), ["properties"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, - ["mediaKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["mediaKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, ["mediaTypeAlias"] = new JsonObject { ["type"] = "string" }, ["crops"] = BuildCropsSchema(config), ["focalPoint"] = BuildFocalPointSchema(config), diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs index 272117ac5680..79709222b042 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiNodeTreePickerPropertyEditor.cs @@ -24,7 +24,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MultiNodeTreePicker, ValueType = ValueTypes.Text, ValueEditorIsReusable = true)] -public class MultiNodeTreePickerPropertyEditor : DataEditor +public class MultiNodeTreePickerPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -38,6 +38,57 @@ public MultiNodeTreePickerPropertyEditor(IDataValueEditorFactory dataValueEditor SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(MultiNodeTreePickerPropertyValueEditor.EditorEntityReference[]); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["type"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray("content", "media", "member"), + ["description"] = "Entity type (content, media, or member)", + }, + ["unique"] = new JsonObject + { + ["type"] = "string", + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "GUID of the selected entity", + }, + }, + ["required"] = new JsonArray("type", "unique"), + }, + ["description"] = "Array of selected entity references", + }; + + // Add minItems/maxItems from configuration if available + if (configuration is MultiNodePickerConfiguration pickerConfig) + { + if (pickerConfig.MinNumber > 0) + { + schema["minItems"] = pickerConfig.MinNumber; + } + + if (pickerConfig.MaxNumber > 0) + { + schema["maxItems"] = pickerConfig.MaxNumber; + } + } + + return schema; + } + /// protected override IConfigurationEditor CreateConfigurationEditor() => new MultiNodePickerConfigurationEditor(_ioHelper); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerPropertyEditor.cs index 921d57d68ba0..1cc52890b066 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerPropertyEditor.cs @@ -1,8 +1,10 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; namespace Umbraco.Cms.Core.PropertyEditors; @@ -10,7 +12,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MultiUrlPicker, ValueType = ValueTypes.Json, ValueEditorIsReusable = true)] -public class MultiUrlPickerPropertyEditor : DataEditor +public class MultiUrlPickerPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -23,6 +25,76 @@ public MultiUrlPickerPropertyEditor(IIOHelper ioHelper, IDataValueEditorFactory public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory(); + /// + public Type? GetValueType(object? configuration) => typeof(LinkDisplay[]); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["name"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Display name of the link", + }, + ["target"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Target attribute (e.g., '_blank')", + }, + ["type"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["enum"] = new JsonArray("document", "media", "external", null), + ["description"] = "Link type (document, media, or external)", + }, + ["unique"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "GUID of linked content/media (for document/media types)", + }, + ["url"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "URL (for external links)", + }, + ["queryString"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Query string portion of the URL", + }, + }, + }, + ["description"] = "Array of link objects", + }; + + // Add minItems/maxItems from configuration if available + if (configuration is MultiUrlPickerConfiguration pickerConfig) + { + if (pickerConfig.MinNumber > 0) + { + schema["minItems"] = pickerConfig.MinNumber; + } + + if (pickerConfig.MaxNumber > 0) + { + schema["maxItems"] = pickerConfig.MaxNumber; + } + } + + return schema; + } + protected override IConfigurationEditor CreateConfigurationEditor() => new MultiUrlPickerConfigurationEditor(_ioHelper); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs index 9c6daeb63f0e..0c61dbcfd6ab 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -22,7 +23,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.MultipleTextstring, ValueType = ValueTypes.Text, ValueEditorIsReusable = true)] -public class MultipleTextStringPropertyEditor : DataEditor +public class MultipleTextStringPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -36,6 +37,40 @@ public MultipleTextStringPropertyEditor(IIOHelper ioHelper, IDataValueEditorFact SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(IEnumerable); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "string", + }, + ["description"] = "Array of text strings", + }; + + // Add min/max items from configuration if available + if (configuration is MultipleTextStringConfiguration textStringConfig) + { + if (textStringConfig.Min > 0) + { + schema["minItems"] = textStringConfig.Min; + } + + if (textStringConfig.Max > 0) + { + schema["maxItems"] = textStringConfig.Max; + } + } + + return schema; + } + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RadioButtonsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RadioButtonsPropertyEditor.cs index cf2c4ad3fa58..a33d78a01532 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RadioButtonsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RadioButtonsPropertyEditor.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Validation; @@ -19,7 +20,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.RadioButtonList, ValueType = ValueTypes.String, ValueEditorIsReusable = true)] -public class RadioButtonsPropertyEditor : DataEditor +public class RadioButtonsPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; @@ -35,8 +36,18 @@ public RadioButtonsPropertyEditor(IDataValueEditorFactory dataValueEditorFactory SupportsReadOnly = true; } - /// + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + ["description"] = "Selected value from the radio button list", + }; + /// protected override IConfigurationEditor CreateConfigurationEditor() => new ValueListConfigurationEditor(_ioHelper, _configurationEditorJsonSerializer); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 89415fb2589a..c0be2916face 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Cache.PropertyEditors; @@ -28,7 +29,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.RichText, ValueType = ValueTypes.Text, ValueEditorIsReusable = true)] -public class RichTextPropertyEditor : DataEditor +public class RichTextPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; private readonly IRichTextPropertyIndexValueFactory _richTextPropertyIndexValueFactory; @@ -55,6 +56,31 @@ public RichTextPropertyEditor( public override bool SupportsConfigurableElements => true; + /// + public Type? GetValueType(object? configuration) => typeof(RichTextEditorValue); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["markup"] = new JsonObject + { + ["type"] = "string", + ["description"] = "HTML markup content", + }, + ["blocks"] = new JsonObject + { + ["type"] = new JsonArray("object", "null"), + ["description"] = "Block editor data (configuration-dependent structure)", + }, + }, + ["required"] = new JsonArray("markup"), + ["description"] = "Rich text editor value with HTML markup and optional blocks", + }; + /// public override bool CanMergePartialPropertyValues(IPropertyType propertyType) => propertyType.VariesByCulture() is false; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs index 7f38484f58d0..7e07ebb947a7 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/SliderPropertyEditor.cs @@ -23,7 +23,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.Slider, ValueEditorIsReusable = true)] -public class SliderPropertyEditor : DataEditor +public class SliderPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -37,6 +37,55 @@ public SliderPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOH SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(SliderPropertyValueEditor.SliderRange); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["from"] = new JsonObject + { + ["type"] = "number", + ["description"] = "Slider range start value", + }, + ["to"] = new JsonObject + { + ["type"] = "number", + ["description"] = "Slider range end value (same as 'from' for single-value slider)", + }, + }, + ["required"] = new JsonArray("from", "to"), + ["description"] = "Slider value with from/to range", + }; + + // Add min/max constraints from configuration if available + if (configuration is SliderConfiguration sliderConfig) + { + var fromSchema = (JsonObject)schema["properties"]!["from"]!; + var toSchema = (JsonObject)schema["properties"]!["to"]!; + + if (sliderConfig.MinimumValue != 0) + { + fromSchema["minimum"] = sliderConfig.MinimumValue; + toSchema["minimum"] = sliderConfig.MinimumValue; + } + + if (sliderConfig.MaximumValue != 0) + { + fromSchema["maximum"] = sliderConfig.MaximumValue; + toSchema["maximum"] = sliderConfig.MaximumValue; + } + } + + return schema; + } + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs index ab56bc14ee00..4227761b68de 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; @@ -22,7 +23,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; Constants.PropertyEditors.Aliases.Tags, ValueEditorIsReusable = true, ValueType = ValueTypes.Text)] -public class TagsPropertyEditor : DataEditor +public class TagsPropertyEditor : DataEditor, IValueSchemaProvider { private readonly ITagPropertyIndexValueFactory _tagPropertyIndexValueFactory; private readonly IIOHelper _ioHelper; @@ -39,6 +40,21 @@ public TagsPropertyEditor( public override IPropertyIndexValueFactory PropertyIndexValueFactory => _tagPropertyIndexValueFactory; + /// + public Type? GetValueType(object? configuration) => typeof(IEnumerable); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("array", "null"), + ["items"] = new JsonObject + { + ["type"] = "string", + }, + ["description"] = "Array of tag values", + }; + protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TextboxPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TextboxPropertyEditor.cs index 734bd539329b..348e2b29e941 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TextboxPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TextboxPropertyEditor.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json.Nodes; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -12,7 +13,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataEditor( Constants.PropertyEditors.Aliases.TextBox, ValueEditorIsReusable = true)] -public class TextboxPropertyEditor : DataEditor +public class TextboxPropertyEditor : DataEditor, IValueSchemaProvider { private readonly IIOHelper _ioHelper; @@ -26,6 +27,35 @@ public TextboxPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIO SupportsReadOnly = true; } + /// + public Type? GetValueType(object? configuration) => typeof(string); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + var schema = new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("string", "null"), + }; + + // Add maxLength constraint from configuration if available + if (configuration is TextboxConfiguration textboxConfig && textboxConfig.MaxChars > 0) + { + schema["maxLength"] = textboxConfig.MaxChars; + } + else if (configuration is IDictionary configDict && + configDict.TryGetValue("maxChars", out var maxCharsValue)) + { + if (maxCharsValue is int maxChars && maxChars > 0) + { + schema["maxLength"] = maxChars; + } + } + + return schema; + } + /// protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs index 0c48f8612c68..bd6c128cc413 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TrueFalsePropertyEditor.cs @@ -28,15 +28,14 @@ public TrueFalsePropertyEditor(IDataValueEditorFactory dataValueEditorFactory) => SupportsReadOnly = true; /// - public Type? GetValueType(object? configuration) => typeof(int); + public Type? GetValueType(object? configuration) => typeof(bool); /// public JsonObject? GetValueSchema(object? configuration) => new() { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", - ["type"] = "integer", - ["enum"] = new JsonArray(0, 1), - ["description"] = "Boolean value stored as integer: 0 = false, 1 = true", + ["type"] = "boolean", + ["description"] = "Boolean value (true or false)", }; /// From 697c101f34235eca2782b3cd42d194488cc70344 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 12 Feb 2026 23:16:59 +0100 Subject: [PATCH 09/24] Add ContentTypeInputSchema --- .../InputSchemaDocumentTypeController.cs | 54 +++++ .../InputSchemaMediaTypeController.cs | 54 +++++ .../InputSchemaMemberTypeController.cs | 54 +++++ .../ContentTypeInputSchemaResponseModel.cs | 32 +++ .../PropertyInputSchemaResponseModel.cs | 32 +++ .../DependencyInjection/UmbracoBuilder.cs | 1 + .../Models/ContentTypeInputSchema.cs | 32 +++ .../Models/PropertyInputSchema.cs | 32 +++ .../Services/ContentTypeInputSchemaService.cs | 82 +++++++ .../IContentTypeInputSchemaService.cs | 48 ++++ .../ContentTypeInputSchemaServiceTests.cs | 213 ++++++++++++++++++ 11 files changed, 634 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs create mode 100644 src/Umbraco.Core/Models/ContentTypeInputSchema.cs create mode 100644 src/Umbraco.Core/Models/PropertyInputSchema.cs create mode 100644 src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs create mode 100644 src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs new file mode 100644 index 000000000000..2aee83c16585 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs @@ -0,0 +1,54 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.ContentType; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; + +/// +/// Controller for retrieving document type input schemas. +/// +[ApiVersion("1.0")] +public class InputSchemaDocumentTypeController : DocumentTypeControllerBase +{ + private readonly IContentTypeInputSchemaService _inputSchemaService; + + /// + /// Initializes a new instance of the class. + /// + public InputSchemaDocumentTypeController(IContentTypeInputSchemaService inputSchemaService) + => _inputSchemaService = inputSchemaService; + + /// + /// Gets input schemas for specific document types. + /// + /// The keys of the document types to retrieve schemas for. + /// The input schema information for the requested document types. + [HttpGet("schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetInputSchemas([FromQuery(Name = "key")] IEnumerable keys) + { + IReadOnlyCollection schemas = await _inputSchemaService.GetDocumentTypeSchemasAsync(keys); + return Ok(schemas.Select(MapToResponseModel)); + } + + private static ContentTypeInputSchemaResponseModel MapToResponseModel(ContentTypeInputSchema schema) + => new() + { + Id = schema.Key, + Alias = schema.Alias, + Properties = schema.Properties.Select(p => new PropertyInputSchemaResponseModel + { + Alias = p.Alias, + DataTypeId = p.DataTypeKey, + EditorAlias = p.EditorAlias, + Mandatory = p.Mandatory, + Variations = p.Variations.ToString(), + }), + IsElement = schema.IsElement, + Variations = schema.Variations.ToString(), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs new file mode 100644 index 000000000000..8aaf2e9055b7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs @@ -0,0 +1,54 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.ContentType; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.MediaType; + +/// +/// Controller for retrieving media type input schemas. +/// +[ApiVersion("1.0")] +public class InputSchemaMediaTypeController : MediaTypeControllerBase +{ + private readonly IContentTypeInputSchemaService _inputSchemaService; + + /// + /// Initializes a new instance of the class. + /// + public InputSchemaMediaTypeController(IContentTypeInputSchemaService inputSchemaService) + => _inputSchemaService = inputSchemaService; + + /// + /// Gets input schemas for specific media types. + /// + /// The keys of the media types to retrieve schemas for. + /// The input schema information for the requested media types. + [HttpGet("schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetInputSchemas([FromQuery(Name = "key")] IEnumerable keys) + { + IReadOnlyCollection schemas = await _inputSchemaService.GetMediaTypeSchemasAsync(keys); + return Ok(schemas.Select(MapToResponseModel)); + } + + private static ContentTypeInputSchemaResponseModel MapToResponseModel(ContentTypeInputSchema schema) + => new() + { + Id = schema.Key, + Alias = schema.Alias, + Properties = schema.Properties.Select(p => new PropertyInputSchemaResponseModel + { + Alias = p.Alias, + DataTypeId = p.DataTypeKey, + EditorAlias = p.EditorAlias, + Mandatory = p.Mandatory, + Variations = p.Variations.ToString(), + }), + IsElement = schema.IsElement, + Variations = schema.Variations.ToString(), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs new file mode 100644 index 000000000000..18bec8fe2786 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs @@ -0,0 +1,54 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.ContentType; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberType; + +/// +/// Controller for retrieving member type input schemas. +/// +[ApiVersion("1.0")] +public class InputSchemaMemberTypeController : MemberTypeControllerBase +{ + private readonly IContentTypeInputSchemaService _inputSchemaService; + + /// + /// Initializes a new instance of the class. + /// + public InputSchemaMemberTypeController(IContentTypeInputSchemaService inputSchemaService) + => _inputSchemaService = inputSchemaService; + + /// + /// Gets input schemas for specific member types. + /// + /// The keys of the member types to retrieve schemas for. + /// The input schema information for the requested member types. + [HttpGet("schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetInputSchemas([FromQuery(Name = "key")] IEnumerable keys) + { + IReadOnlyCollection schemas = await _inputSchemaService.GetMemberTypeSchemasAsync(keys); + return Ok(schemas.Select(MapToResponseModel)); + } + + private static ContentTypeInputSchemaResponseModel MapToResponseModel(ContentTypeInputSchema schema) + => new() + { + Id = schema.Key, + Alias = schema.Alias, + Properties = schema.Properties.Select(p => new PropertyInputSchemaResponseModel + { + Alias = p.Alias, + DataTypeId = p.DataTypeKey, + EditorAlias = p.EditorAlias, + Mandatory = p.Mandatory, + Variations = p.Variations.ToString(), + }), + IsElement = schema.IsElement, + Variations = schema.Variations.ToString(), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs new file mode 100644 index 000000000000..d9566b711dc8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.ContentType; + +/// +/// Represents content type input schema information in API responses. +/// +public class ContentTypeInputSchemaResponseModel +{ + /// + /// Gets or sets the unique key of the content type. + /// + public required Guid Id { get; set; } + + /// + /// Gets or sets the content type alias. + /// + public required string Alias { get; set; } + + /// + /// Gets or sets all properties for this content type. + /// + public required IEnumerable Properties { get; set; } + + /// + /// Gets or sets a value indicating whether the content type is an element type. + /// + public bool IsElement { get; set; } + + /// + /// Gets or sets the content variation setting for this content type. + /// + public required string Variations { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs new file mode 100644 index 000000000000..dda1d0588ae8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.ContentType; + +/// +/// Represents property input schema information in API responses. +/// +public class PropertyInputSchemaResponseModel +{ + /// + /// Gets or sets the property alias. + /// + public required string Alias { get; set; } + + /// + /// Gets or sets the unique key of the data type used by this property. + /// + public required Guid DataTypeId { get; set; } + + /// + /// Gets or sets the property editor alias. + /// + public required string EditorAlias { get; set; } + + /// + /// Gets or sets a value indicating whether a value is required for this property. + /// + public bool Mandatory { get; set; } + + /// + /// Gets or sets the content variation setting for this property. + /// + public required string Variations { get; set; } +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 3d13fe7c58aa..e1f4cf413557 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -297,6 +297,7 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Models/ContentTypeInputSchema.cs b/src/Umbraco.Core/Models/ContentTypeInputSchema.cs new file mode 100644 index 000000000000..236501ba94b9 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentTypeInputSchema.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents content type information needed for inputting content values. +/// +public class ContentTypeInputSchema +{ + /// + /// Gets the unique key of the content type. + /// + public required Guid Key { get; init; } + + /// + /// Gets the content type alias. + /// + public required string Alias { get; init; } + + /// + /// Gets all properties for this content type (including inherited from compositions). + /// + public required IReadOnlyList Properties { get; init; } + + /// + /// Gets a value indicating whether the content type is an element type. + /// + public bool IsElement { get; init; } + + /// + /// Gets the content variation setting for this content type. + /// + public ContentVariation Variations { get; init; } +} diff --git a/src/Umbraco.Core/Models/PropertyInputSchema.cs b/src/Umbraco.Core/Models/PropertyInputSchema.cs new file mode 100644 index 000000000000..b771f9c70831 --- /dev/null +++ b/src/Umbraco.Core/Models/PropertyInputSchema.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Represents property information needed for inputting content values. +/// +public class PropertyInputSchema +{ + /// + /// Gets the property alias. + /// + public required string Alias { get; init; } + + /// + /// Gets the unique key of the data type used by this property. + /// + public required Guid DataTypeKey { get; init; } + + /// + /// Gets the property editor alias. + /// + public required string EditorAlias { get; init; } + + /// + /// Gets a value indicating whether a value is required for this property. + /// + public bool Mandatory { get; init; } + + /// + /// Gets the content variation setting for this property. + /// + public ContentVariation Variations { get; init; } +} diff --git a/src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs b/src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs new file mode 100644 index 000000000000..4edb84ad5939 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs @@ -0,0 +1,82 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +/// +internal sealed class ContentTypeInputSchemaService : IContentTypeInputSchemaService +{ + private readonly IContentTypeService _contentTypeService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IMemberTypeService _memberTypeService; + + /// + /// Initializes a new instance of the class. + /// + public ContentTypeInputSchemaService( + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService) + { + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _memberTypeService = memberTypeService; + } + + /// + public Task> GetDocumentTypeSchemasAsync(IEnumerable keys) + { + Guid[] keyArray = keys.ToArray(); + IEnumerable contentTypes = _contentTypeService.GetMany(keyArray); + return Task.FromResult(BuildSchemaInfos(contentTypes)); + } + + /// + public Task> GetMediaTypeSchemasAsync(IEnumerable keys) + { + Guid[] keyArray = keys.ToArray(); + IEnumerable mediaTypes = _mediaTypeService.GetMany(keyArray); + return Task.FromResult(BuildSchemaInfos(mediaTypes)); + } + + /// + public Task> GetMemberTypeSchemasAsync(IEnumerable keys) + { + Guid[] keyArray = keys.ToArray(); + IEnumerable memberTypes = _memberTypeService.GetMany(keyArray); + return Task.FromResult(BuildSchemaInfos(memberTypes)); + } + + private static IReadOnlyCollection BuildSchemaInfos(IEnumerable contentTypes) + where T : IContentTypeComposition + { + List results = []; + + foreach (T contentType in contentTypes) + { + List properties = []; + + foreach (IPropertyType propertyType in contentType.CompositionPropertyTypes) + { + properties.Add(new PropertyInputSchema + { + Alias = propertyType.Alias, + DataTypeKey = propertyType.DataTypeKey, + EditorAlias = propertyType.PropertyEditorAlias, + Mandatory = propertyType.Mandatory, + Variations = propertyType.Variations, + }); + } + + results.Add(new ContentTypeInputSchema + { + Key = contentType.Key, + Alias = contentType.Alias, + Properties = properties, + IsElement = contentType.IsElement, + Variations = contentType.Variations, + }); + } + + return results; + } +} diff --git a/src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs b/src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs new file mode 100644 index 000000000000..de6050b5a877 --- /dev/null +++ b/src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs @@ -0,0 +1,48 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Service to get content type input schema information for programmatic content creation. +/// +/// +/// +/// This service provides the minimal schema information needed to input content values, +/// including property aliases, data type keys, and variation settings. +/// +/// +/// Property groups and inheritance information are not included as they are not relevant for input. +/// +/// +public interface IContentTypeInputSchemaService +{ + /// + /// Gets input schemas for specific document types by their keys. + /// + /// The unique identifiers of the document types to retrieve. + /// + /// A collection containing the schemas for document types that were found. + /// Returns an empty collection if none of the specified keys were found. + /// + Task> GetDocumentTypeSchemasAsync(IEnumerable keys); + + /// + /// Gets input schemas for specific media types by their keys. + /// + /// The unique identifiers of the media types to retrieve. + /// + /// A collection containing the schemas for media types that were found. + /// Returns an empty collection if none of the specified keys were found. + /// + Task> GetMediaTypeSchemasAsync(IEnumerable keys); + + /// + /// Gets input schemas for specific member types by their keys. + /// + /// The unique identifiers of the member types to retrieve. + /// + /// A collection containing the schemas for member types that were found. + /// Returns an empty collection if none of the specified keys were found. + /// + Task> GetMemberTypeSchemasAsync(IEnumerable keys); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs new file mode 100644 index 000000000000..6e973304ea5a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs @@ -0,0 +1,213 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class ContentTypeInputSchemaServiceTests +{ + private Mock _contentTypeServiceMock = null!; + private Mock _mediaTypeServiceMock = null!; + private Mock _memberTypeServiceMock = null!; + private ContentTypeInputSchemaService _sut = null!; + + [SetUp] + public void SetUp() + { + _contentTypeServiceMock = new Mock(); + _mediaTypeServiceMock = new Mock(); + _memberTypeServiceMock = new Mock(); + + _sut = new ContentTypeInputSchemaService( + _contentTypeServiceMock.Object, + _mediaTypeServiceMock.Object, + _memberTypeServiceMock.Object); + } + + [Test] + public async Task GetDocumentTypeSchemasAsync_ReturnsRequestedTypes() + { + // Arrange + var key = Guid.NewGuid(); + var contentType = CreateMockContentType(key, "testType"); + _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); + + // Act + var result = await _sut.GetDocumentTypeSchemasAsync([key]); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().Alias, Is.EqualTo("testType")); + Assert.That(result.First().Key, Is.EqualTo(key)); + } + + [Test] + public async Task GetDocumentTypeSchemasAsync_ReturnsEmptyWhenNoTypesFound() + { + // Arrange + _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns(Array.Empty()); + + // Act + var result = await _sut.GetDocumentTypeSchemasAsync([Guid.NewGuid()]); + + // Assert + Assert.That(result, Is.Empty); + } + + [Test] + public async Task GetDocumentTypeSchemasAsync_ReturnsOnlyFoundTypes() + { + // Arrange + var foundKey = Guid.NewGuid(); + var notFoundKey = Guid.NewGuid(); + var contentType = CreateMockContentType(foundKey, "foundType"); + _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); + + // Act + var result = await _sut.GetDocumentTypeSchemasAsync([foundKey, notFoundKey]); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().Key, Is.EqualTo(foundKey)); + } + + [Test] + public async Task GetDocumentTypeSchemasAsync_IncludesAllProperties() + { + // Arrange + var key = Guid.NewGuid(); + var dataTypeKey = Guid.NewGuid(); + var propertyType = Mock.Of(p => + p.Alias == "testProperty" && + p.DataTypeKey == dataTypeKey && + p.PropertyEditorAlias == "Umbraco.TextBox" && + p.Mandatory == true && + p.Variations == ContentVariation.Culture); + + var contentType = Mock.Of(x => + x.Key == key && + x.Alias == "testType" && + x.CompositionPropertyTypes == new[] { propertyType } && + x.CompositionKeys() == Array.Empty() && + x.IsElement == false && + x.Variations == ContentVariation.Nothing); + + _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); + + // Act + var result = await _sut.GetDocumentTypeSchemasAsync([key]); + + // Assert + var schema = result.First(); + Assert.That(schema.Properties, Has.Count.EqualTo(1)); + var prop = schema.Properties.First(); + Assert.That(prop.Alias, Is.EqualTo("testProperty")); + Assert.That(prop.DataTypeKey, Is.EqualTo(dataTypeKey)); + Assert.That(prop.EditorAlias, Is.EqualTo("Umbraco.TextBox")); + Assert.That(prop.Mandatory, Is.True); + Assert.That(prop.Variations, Is.EqualTo(ContentVariation.Culture)); + } + + [Test] + public async Task GetDocumentTypeSchemasAsync_CorrectlySetsIsElement() + { + // Arrange + var documentKey = Guid.NewGuid(); + var elementKey = Guid.NewGuid(); + + var documentType = CreateMockContentType(documentKey, "documentType", isElement: false); + var elementType = CreateMockContentType(elementKey, "elementType", isElement: true); + + _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([documentType, elementType]); + + // Act + var result = await _sut.GetDocumentTypeSchemasAsync([documentKey, elementKey]); + + // Assert + Assert.That(result.First(x => x.Alias == "documentType").IsElement, Is.False); + Assert.That(result.First(x => x.Alias == "elementType").IsElement, Is.True); + } + + [Test] + public async Task GetDocumentTypeSchemasAsync_CorrectlySetsVariations() + { + // Arrange + var key = Guid.NewGuid(); + var contentType = Mock.Of(x => + x.Key == key && + x.Alias == "variantType" && + x.CompositionPropertyTypes == Array.Empty() && + x.CompositionKeys() == Array.Empty() && + x.IsElement == false && + x.Variations == ContentVariation.CultureAndSegment); + + _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); + + // Act + var result = await _sut.GetDocumentTypeSchemasAsync([key]); + + // Assert + Assert.That(result.First().Variations, Is.EqualTo(ContentVariation.CultureAndSegment)); + } + + [Test] + public async Task GetMediaTypeSchemasAsync_ReturnsRequestedTypes() + { + // Arrange + var key = Guid.NewGuid(); + var mediaType = CreateMockMediaType(key, "testMediaType"); + _mediaTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([mediaType]); + + // Act + var result = await _sut.GetMediaTypeSchemasAsync([key]); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().Alias, Is.EqualTo("testMediaType")); + } + + [Test] + public async Task GetMemberTypeSchemasAsync_ReturnsRequestedTypes() + { + // Arrange + var key = Guid.NewGuid(); + var memberType = CreateMockMemberType(key, "testMemberType"); + _memberTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([memberType]); + + // Act + var result = await _sut.GetMemberTypeSchemasAsync([key]); + + // Assert + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result.First().Alias, Is.EqualTo("testMemberType")); + } + + private static IContentType CreateMockContentType(Guid key, string alias, bool isElement = false) + => Mock.Of(x => + x.Key == key && + x.Alias == alias && + x.CompositionPropertyTypes == Array.Empty() && + x.CompositionKeys() == Array.Empty() && + x.IsElement == isElement && + x.Variations == ContentVariation.Nothing); + + private static IMediaType CreateMockMediaType(Guid key, string alias) + => Mock.Of(x => + x.Key == key && + x.Alias == alias && + x.CompositionPropertyTypes == Array.Empty() && + x.CompositionKeys() == Array.Empty() && + x.IsElement == false && + x.Variations == ContentVariation.Nothing); + + private static IMemberType CreateMockMemberType(Guid key, string alias) + => Mock.Of(x => + x.Key == key && + x.Alias == alias && + x.CompositionPropertyTypes == Array.Empty() && + x.CompositionKeys() == Array.Empty() && + x.IsElement == false && + x.Variations == ContentVariation.Nothing); +} From 55bab09e91fd7861d672691ca7927213973333ec Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Fri, 13 Feb 2026 15:04:46 +0100 Subject: [PATCH 10/24] move contenttype schemas to be actual jsonschemas --- .../InputSchemaDocumentTypeController.cs | 54 ---- .../SchemaDocumentTypeController.cs | 44 +++ .../InputSchemaMediaTypeController.cs | 54 ---- .../MediaType/SchemaMediaTypeController.cs | 44 +++ .../InputSchemaMemberTypeController.cs | 54 ---- .../MemberType/SchemaMemberTypeController.cs | 44 +++ .../DocumentTypeBuilderExtensions.cs | 2 + .../Services/ContentTypeJsonSchemaService.cs | 275 ++++++++++++++++++ .../Services/IContentTypeJsonSchemaService.cs | 47 +++ .../ContentTypeInputSchemaResponseModel.cs | 32 -- .../PropertyInputSchemaResponseModel.cs | 32 -- .../DependencyInjection/UmbracoBuilder.cs | 1 - .../Models/ContentTypeInputSchema.cs | 32 -- .../Models/PropertyInputSchema.cs | 32 -- .../Services/ContentTypeInputSchemaService.cs | 82 ------ .../IContentTypeInputSchemaService.cs | 48 --- .../ContentTypeInputSchemaServiceTests.cs | 213 -------------- 17 files changed, 456 insertions(+), 634 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DocumentType/SchemaDocumentTypeController.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MediaType/SchemaMediaTypeController.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberType/SchemaMemberTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/IContentTypeJsonSchemaService.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs delete mode 100644 src/Umbraco.Core/Models/ContentTypeInputSchema.cs delete mode 100644 src/Umbraco.Core/Models/PropertyInputSchema.cs delete mode 100644 src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs delete mode 100644 src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs deleted file mode 100644 index 2aee83c16585..000000000000 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/InputSchemaDocumentTypeController.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.ContentType; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; - -/// -/// Controller for retrieving document type input schemas. -/// -[ApiVersion("1.0")] -public class InputSchemaDocumentTypeController : DocumentTypeControllerBase -{ - private readonly IContentTypeInputSchemaService _inputSchemaService; - - /// - /// Initializes a new instance of the class. - /// - public InputSchemaDocumentTypeController(IContentTypeInputSchemaService inputSchemaService) - => _inputSchemaService = inputSchemaService; - - /// - /// Gets input schemas for specific document types. - /// - /// The keys of the document types to retrieve schemas for. - /// The input schema information for the requested document types. - [HttpGet("schema")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task GetInputSchemas([FromQuery(Name = "key")] IEnumerable keys) - { - IReadOnlyCollection schemas = await _inputSchemaService.GetDocumentTypeSchemasAsync(keys); - return Ok(schemas.Select(MapToResponseModel)); - } - - private static ContentTypeInputSchemaResponseModel MapToResponseModel(ContentTypeInputSchema schema) - => new() - { - Id = schema.Key, - Alias = schema.Alias, - Properties = schema.Properties.Select(p => new PropertyInputSchemaResponseModel - { - Alias = p.Alias, - DataTypeId = p.DataTypeKey, - EditorAlias = p.EditorAlias, - Mandatory = p.Mandatory, - Variations = p.Variations.ToString(), - }), - IsElement = schema.IsElement, - Variations = schema.Variations.ToString(), - }; -} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/SchemaDocumentTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/SchemaDocumentTypeController.cs new file mode 100644 index 000000000000..f83ba7a31d23 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/SchemaDocumentTypeController.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Nodes; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.DocumentType; + +/// +/// Controller for retrieving document type JSON schemas. +/// +[ApiVersion("1.0")] +public class SchemaDocumentTypeController : DocumentTypeControllerBase +{ + private readonly IContentTypeJsonSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + public SchemaDocumentTypeController(IContentTypeJsonSchemaService schemaService) + => _schemaService = schemaService; + + /// + /// Gets a JSON Schema for a specific document type. + /// + /// The unique identifier of the document type. + /// A JSON Schema describing the document creation/update payload structure. + /// + /// The returned JSON Schema references data type schemas via external $ref URIs. + /// Tooling should resolve these references by making HTTP requests to the data type schema endpoints. + /// + [HttpGet("{id:guid}/schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(JsonObject), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetSchema(Guid id) + { + JsonObject? schema = await _schemaService.GetDocumentTypeSchemaAsync(id); + return schema is not null + ? Ok(schema) + : OperationStatusResult(ContentTypeOperationStatus.NotFound); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs deleted file mode 100644 index 8aaf2e9055b7..000000000000 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/InputSchemaMediaTypeController.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.ContentType; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Api.Management.Controllers.MediaType; - -/// -/// Controller for retrieving media type input schemas. -/// -[ApiVersion("1.0")] -public class InputSchemaMediaTypeController : MediaTypeControllerBase -{ - private readonly IContentTypeInputSchemaService _inputSchemaService; - - /// - /// Initializes a new instance of the class. - /// - public InputSchemaMediaTypeController(IContentTypeInputSchemaService inputSchemaService) - => _inputSchemaService = inputSchemaService; - - /// - /// Gets input schemas for specific media types. - /// - /// The keys of the media types to retrieve schemas for. - /// The input schema information for the requested media types. - [HttpGet("schema")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task GetInputSchemas([FromQuery(Name = "key")] IEnumerable keys) - { - IReadOnlyCollection schemas = await _inputSchemaService.GetMediaTypeSchemasAsync(keys); - return Ok(schemas.Select(MapToResponseModel)); - } - - private static ContentTypeInputSchemaResponseModel MapToResponseModel(ContentTypeInputSchema schema) - => new() - { - Id = schema.Key, - Alias = schema.Alias, - Properties = schema.Properties.Select(p => new PropertyInputSchemaResponseModel - { - Alias = p.Alias, - DataTypeId = p.DataTypeKey, - EditorAlias = p.EditorAlias, - Mandatory = p.Mandatory, - Variations = p.Variations.ToString(), - }), - IsElement = schema.IsElement, - Variations = schema.Variations.ToString(), - }; -} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/SchemaMediaTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/SchemaMediaTypeController.cs new file mode 100644 index 000000000000..309105891cbb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/SchemaMediaTypeController.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Nodes; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.MediaType; + +/// +/// Controller for retrieving media type JSON schemas. +/// +[ApiVersion("1.0")] +public class SchemaMediaTypeController : MediaTypeControllerBase +{ + private readonly IContentTypeJsonSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + public SchemaMediaTypeController(IContentTypeJsonSchemaService schemaService) + => _schemaService = schemaService; + + /// + /// Gets a JSON Schema for a specific media type. + /// + /// The unique identifier of the media type. + /// A JSON Schema describing the media creation/update payload structure. + /// + /// The returned JSON Schema references data type schemas via external $ref URIs. + /// Tooling should resolve these references by making HTTP requests to the data type schema endpoints. + /// + [HttpGet("{id:guid}/schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(JsonObject), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetSchema(Guid id) + { + JsonObject? schema = await _schemaService.GetMediaTypeSchemaAsync(id); + return schema is not null + ? Ok(schema) + : OperationStatusResult(ContentTypeOperationStatus.NotFound); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs deleted file mode 100644 index 18bec8fe2786..000000000000 --- a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/InputSchemaMemberTypeController.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.ContentType; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Api.Management.Controllers.MemberType; - -/// -/// Controller for retrieving member type input schemas. -/// -[ApiVersion("1.0")] -public class InputSchemaMemberTypeController : MemberTypeControllerBase -{ - private readonly IContentTypeInputSchemaService _inputSchemaService; - - /// - /// Initializes a new instance of the class. - /// - public InputSchemaMemberTypeController(IContentTypeInputSchemaService inputSchemaService) - => _inputSchemaService = inputSchemaService; - - /// - /// Gets input schemas for specific member types. - /// - /// The keys of the member types to retrieve schemas for. - /// The input schema information for the requested member types. - [HttpGet("schema")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task GetInputSchemas([FromQuery(Name = "key")] IEnumerable keys) - { - IReadOnlyCollection schemas = await _inputSchemaService.GetMemberTypeSchemasAsync(keys); - return Ok(schemas.Select(MapToResponseModel)); - } - - private static ContentTypeInputSchemaResponseModel MapToResponseModel(ContentTypeInputSchema schema) - => new() - { - Id = schema.Key, - Alias = schema.Alias, - Properties = schema.Properties.Select(p => new PropertyInputSchemaResponseModel - { - Alias = p.Alias, - DataTypeId = p.DataTypeKey, - EditorAlias = p.EditorAlias, - Mandatory = p.Mandatory, - Variations = p.Variations.ToString(), - }), - IsElement = schema.IsElement, - Variations = schema.Variations.ToString(), - }; -} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/SchemaMemberTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/SchemaMemberTypeController.cs new file mode 100644 index 000000000000..03236fe3b0fb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/SchemaMemberTypeController.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Nodes; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberType; + +/// +/// Controller for retrieving member type JSON schemas. +/// +[ApiVersion("1.0")] +public class SchemaMemberTypeController : MemberTypeControllerBase +{ + private readonly IContentTypeJsonSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + public SchemaMemberTypeController(IContentTypeJsonSchemaService schemaService) + => _schemaService = schemaService; + + /// + /// Gets a JSON Schema for a specific member type. + /// + /// The unique identifier of the member type. + /// A JSON Schema describing the member creation/update payload structure. + /// + /// The returned JSON Schema references data type schemas via external $ref URIs. + /// Tooling should resolve these references by making HTTP requests to the data type schema endpoints. + /// + [HttpGet("{id:guid}/schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(JsonObject), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task GetSchema(Guid id) + { + JsonObject? schema = await _schemaService.GetMemberTypeSchemaAsync(id); + return schema is not null + ? Ok(schema) + : OperationStatusResult(ContentTypeOperationStatus.NotFound); + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentTypeBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentTypeBuilderExtensions.cs index 596676bc1912..899d27ad3474 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentTypeBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentTypeBuilderExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.DocumentType; +using Umbraco.Cms.Api.Management.Services; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; @@ -11,6 +12,7 @@ internal static class DocumentTypeBuilderExtensions internal static IUmbracoBuilder AddDocumentTypes(this IUmbracoBuilder builder) { builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder().Add(); builder.WithCollectionBuilder().Add(); diff --git a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs new file mode 100644 index 000000000000..1cd50679d49a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs @@ -0,0 +1,275 @@ +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Provides services for generating JSON Schema for content types. +/// +internal sealed class ContentTypeJsonSchemaService : IContentTypeJsonSchemaService +{ + private const string JsonSchemaVersion = "https://json-schema.org/draft/2020-12/schema"; + private const string DataTypeSchemaEndpointTemplate = "/umbraco/management/api/v1/data-type/{0}/schema"; + + private readonly IContentTypeService _contentTypeService; + private readonly IMediaTypeService _mediaTypeService; + private readonly IMemberTypeService _memberTypeService; + private readonly IPropertyEditorSchemaService _propertyEditorSchemaService; + + /// + /// Initializes a new instance of the class. + /// + public ContentTypeJsonSchemaService( + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + IPropertyEditorSchemaService propertyEditorSchemaService) + { + _contentTypeService = contentTypeService; + _mediaTypeService = mediaTypeService; + _memberTypeService = memberTypeService; + _propertyEditorSchemaService = propertyEditorSchemaService; + } + + /// + public Task GetDocumentTypeSchemaAsync(Guid key) + { + IContentType? contentType = _contentTypeService.Get(key); + return Task.FromResult(contentType is null ? null : BuildSchema(contentType, "document")); + } + + /// + public Task GetMediaTypeSchemaAsync(Guid key) + { + IMediaType? mediaType = _mediaTypeService.Get(key); + return Task.FromResult(mediaType is null ? null : BuildSchema(mediaType, "media")); + } + + /// + public Task GetMemberTypeSchemaAsync(Guid key) + { + IMemberType? memberType = _memberTypeService.Get(key); + return Task.FromResult(memberType is null ? null : BuildSchema(memberType, "member")); + } + + private JsonObject BuildSchema(IContentTypeComposition contentType, string contentKind) + { + var schema = new JsonObject + { + ["$schema"] = JsonSchemaVersion, + ["$id"] = $"urn:umbraco:{contentKind}-type:{contentType.Key}", + ["title"] = $"Create {contentType.Name}", + ["description"] = $"JSON Schema for creating/updating {contentKind} content of type '{contentType.Alias}'", + ["type"] = "object", + ["required"] = new JsonArray($"{contentKind}Type", "values", "variants"), + }; + + // Build properties + var properties = new JsonObject + { + [$"{contentKind}Type"] = BuildContentTypeReference(contentType.Key), + ["id"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["description"] = "Optional key for the new content item", + }, + ["values"] = BuildValuesSchema(contentType), + ["variants"] = new JsonObject { ["$ref"] = "#/$defs/variants" }, + }; + + // Add parent reference for documents and media (not members) + if (contentKind is "document" or "media") + { + properties["parent"] = new JsonObject { ["$ref"] = "#/$defs/referenceById" }; + } + + // Add template reference for documents only + if (contentKind == "document") + { + properties["template"] = new JsonObject { ["$ref"] = "#/$defs/referenceById" }; + } + + schema["properties"] = properties; + + // Build $defs + schema["$defs"] = BuildDefs(); + + // Build x-umbraco-content-type metadata + schema["x-umbraco-content-type"] = new JsonObject + { + ["key"] = contentType.Key.ToString(), + ["alias"] = contentType.Alias, + ["isElement"] = contentType.IsElement, + ["variations"] = contentType.Variations.ToString(), + }; + + return schema; + } + + private static JsonObject BuildContentTypeReference(Guid contentTypeKey) + => new() + { + ["type"] = "object", + ["required"] = new JsonArray("id"), + ["properties"] = new JsonObject + { + ["id"] = new JsonObject + { + ["const"] = contentTypeKey.ToString(), + ["description"] = "Content type identifier", + }, + }, + }; + + private JsonObject BuildValuesSchema(IContentTypeComposition contentType) + { + IPropertyType[] propertyTypes = contentType.CompositionPropertyTypes.ToArray(); + + // Build the items schema with if/then clauses for each property + var itemsAllOf = new JsonArray + { + new JsonObject { ["$ref"] = "#/$defs/valueBase" }, + }; + + // Add if/then clause for each property that supports schema + foreach (IPropertyType propertyType in propertyTypes) + { + JsonObject ifThen = BuildPropertyIfThen(propertyType); + itemsAllOf.Add(ifThen); + } + + // Build x-umbraco-properties metadata + var propertiesMetadata = new JsonObject(); + foreach (IPropertyType propertyType in propertyTypes) + { + propertiesMetadata[propertyType.Alias] = new JsonObject + { + ["dataTypeId"] = propertyType.DataTypeKey.ToString(), + ["editorAlias"] = propertyType.PropertyEditorAlias, + ["mandatory"] = propertyType.Mandatory, + ["variations"] = propertyType.Variations.ToString(), + }; + } + + return new JsonObject + { + ["type"] = "array", + ["description"] = "Property values for the content item", + ["items"] = new JsonObject { ["allOf"] = itemsAllOf }, + ["x-umbraco-properties"] = propertiesMetadata, + }; + } + + private JsonObject BuildPropertyIfThen(IPropertyType propertyType) + { + var thenProperties = new JsonObject(); + + // Check if the property editor supports schema + if (_propertyEditorSchemaService.SupportsSchema(propertyType.PropertyEditorAlias)) + { + // Add $ref to the data type schema endpoint + thenProperties["value"] = new JsonObject + { + ["$ref"] = string.Format(DataTypeSchemaEndpointTemplate, propertyType.DataTypeKey), + }; + } + + // If no schema support, value can be anything (already defined in valueBase) + + return new JsonObject + { + ["if"] = new JsonObject + { + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject { ["const"] = propertyType.Alias }, + }, + }, + ["then"] = new JsonObject { ["properties"] = thenProperties }, + }; + } + + private static JsonObject BuildDefs() + => new() + { + ["referenceById"] = new JsonObject + { + ["oneOf"] = new JsonArray + { + new JsonObject { ["type"] = "null" }, + new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("id"), + ["properties"] = new JsonObject + { + ["id"] = new JsonObject + { + ["type"] = "string", + ["format"] = "uuid", + }, + }, + }, + }, + }, + ["valueBase"] = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("alias"), + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Property type alias", + }, + ["culture"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Culture code for variant properties, or null for invariant", + }, + ["segment"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Segment identifier, or null for non-segmented", + }, + ["value"] = new JsonObject + { + ["description"] = "Property value (type depends on property editor)", + }, + }, + }, + ["variants"] = new JsonObject + { + ["type"] = "array", + ["minItems"] = 1, + ["description"] = "Content variants (at minimum one variant with the name is required)", + ["items"] = new JsonObject + { + ["type"] = "object", + ["required"] = new JsonArray("name"), + ["properties"] = new JsonObject + { + ["name"] = new JsonObject + { + ["type"] = "string", + ["minLength"] = 1, + ["description"] = "Content name for this variant", + }, + ["culture"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Culture code for this variant, or null for invariant", + }, + ["segment"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["description"] = "Segment identifier, or null for non-segmented", + }, + }, + }, + }, + }; +} diff --git a/src/Umbraco.Cms.Api.Management/Services/IContentTypeJsonSchemaService.cs b/src/Umbraco.Cms.Api.Management/Services/IContentTypeJsonSchemaService.cs new file mode 100644 index 000000000000..d8c3d599521e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/IContentTypeJsonSchemaService.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Provides services for generating JSON Schema for content types. +/// +/// +/// +/// This service generates JSON Schema (draft 2020-12) that describes the structure of content +/// creation payloads for specific content types (documents, media, members). +/// +/// +/// The generated schemas reference data type schemas via external $ref URIs to the +/// /umbraco/management/api/v1/data-type/{id}/schema endpoint. Tooling should resolve +/// these references by making HTTP requests to the referenced endpoints. +/// +/// +public interface IContentTypeJsonSchemaService +{ + /// + /// Gets a JSON Schema for creating/updating documents of a specific document type. + /// + /// The unique key of the document type. + /// + /// A JSON Schema as a , or null if the document type was not found. + /// + Task GetDocumentTypeSchemaAsync(Guid key); + + /// + /// Gets a JSON Schema for creating/updating media of a specific media type. + /// + /// The unique key of the media type. + /// + /// A JSON Schema as a , or null if the media type was not found. + /// + Task GetMediaTypeSchemaAsync(Guid key); + + /// + /// Gets a JSON Schema for creating/updating members of a specific member type. + /// + /// The unique key of the member type. + /// + /// A JSON Schema as a , or null if the member type was not found. + /// + Task GetMemberTypeSchemaAsync(Guid key); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs deleted file mode 100644 index d9566b711dc8..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeInputSchemaResponseModel.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.ContentType; - -/// -/// Represents content type input schema information in API responses. -/// -public class ContentTypeInputSchemaResponseModel -{ - /// - /// Gets or sets the unique key of the content type. - /// - public required Guid Id { get; set; } - - /// - /// Gets or sets the content type alias. - /// - public required string Alias { get; set; } - - /// - /// Gets or sets all properties for this content type. - /// - public required IEnumerable Properties { get; set; } - - /// - /// Gets or sets a value indicating whether the content type is an element type. - /// - public bool IsElement { get; set; } - - /// - /// Gets or sets the content variation setting for this content type. - /// - public required string Variations { get; set; } -} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs deleted file mode 100644 index dda1d0588ae8..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/PropertyInputSchemaResponseModel.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.ContentType; - -/// -/// Represents property input schema information in API responses. -/// -public class PropertyInputSchemaResponseModel -{ - /// - /// Gets or sets the property alias. - /// - public required string Alias { get; set; } - - /// - /// Gets or sets the unique key of the data type used by this property. - /// - public required Guid DataTypeId { get; set; } - - /// - /// Gets or sets the property editor alias. - /// - public required string EditorAlias { get; set; } - - /// - /// Gets or sets a value indicating whether a value is required for this property. - /// - public bool Mandatory { get; set; } - - /// - /// Gets or sets the content variation setting for this property. - /// - public required string Variations { get; set; } -} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index e1f4cf413557..3d13fe7c58aa 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -297,7 +297,6 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); - Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Models/ContentTypeInputSchema.cs b/src/Umbraco.Core/Models/ContentTypeInputSchema.cs deleted file mode 100644 index 236501ba94b9..000000000000 --- a/src/Umbraco.Core/Models/ContentTypeInputSchema.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Umbraco.Cms.Core.Models; - -/// -/// Represents content type information needed for inputting content values. -/// -public class ContentTypeInputSchema -{ - /// - /// Gets the unique key of the content type. - /// - public required Guid Key { get; init; } - - /// - /// Gets the content type alias. - /// - public required string Alias { get; init; } - - /// - /// Gets all properties for this content type (including inherited from compositions). - /// - public required IReadOnlyList Properties { get; init; } - - /// - /// Gets a value indicating whether the content type is an element type. - /// - public bool IsElement { get; init; } - - /// - /// Gets the content variation setting for this content type. - /// - public ContentVariation Variations { get; init; } -} diff --git a/src/Umbraco.Core/Models/PropertyInputSchema.cs b/src/Umbraco.Core/Models/PropertyInputSchema.cs deleted file mode 100644 index b771f9c70831..000000000000 --- a/src/Umbraco.Core/Models/PropertyInputSchema.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Umbraco.Cms.Core.Models; - -/// -/// Represents property information needed for inputting content values. -/// -public class PropertyInputSchema -{ - /// - /// Gets the property alias. - /// - public required string Alias { get; init; } - - /// - /// Gets the unique key of the data type used by this property. - /// - public required Guid DataTypeKey { get; init; } - - /// - /// Gets the property editor alias. - /// - public required string EditorAlias { get; init; } - - /// - /// Gets a value indicating whether a value is required for this property. - /// - public bool Mandatory { get; init; } - - /// - /// Gets the content variation setting for this property. - /// - public ContentVariation Variations { get; init; } -} diff --git a/src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs b/src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs deleted file mode 100644 index 4edb84ad5939..000000000000 --- a/src/Umbraco.Core/Services/ContentTypeInputSchemaService.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Umbraco.Cms.Core.Models; - -namespace Umbraco.Cms.Core.Services; - -/// -internal sealed class ContentTypeInputSchemaService : IContentTypeInputSchemaService -{ - private readonly IContentTypeService _contentTypeService; - private readonly IMediaTypeService _mediaTypeService; - private readonly IMemberTypeService _memberTypeService; - - /// - /// Initializes a new instance of the class. - /// - public ContentTypeInputSchemaService( - IContentTypeService contentTypeService, - IMediaTypeService mediaTypeService, - IMemberTypeService memberTypeService) - { - _contentTypeService = contentTypeService; - _mediaTypeService = mediaTypeService; - _memberTypeService = memberTypeService; - } - - /// - public Task> GetDocumentTypeSchemasAsync(IEnumerable keys) - { - Guid[] keyArray = keys.ToArray(); - IEnumerable contentTypes = _contentTypeService.GetMany(keyArray); - return Task.FromResult(BuildSchemaInfos(contentTypes)); - } - - /// - public Task> GetMediaTypeSchemasAsync(IEnumerable keys) - { - Guid[] keyArray = keys.ToArray(); - IEnumerable mediaTypes = _mediaTypeService.GetMany(keyArray); - return Task.FromResult(BuildSchemaInfos(mediaTypes)); - } - - /// - public Task> GetMemberTypeSchemasAsync(IEnumerable keys) - { - Guid[] keyArray = keys.ToArray(); - IEnumerable memberTypes = _memberTypeService.GetMany(keyArray); - return Task.FromResult(BuildSchemaInfos(memberTypes)); - } - - private static IReadOnlyCollection BuildSchemaInfos(IEnumerable contentTypes) - where T : IContentTypeComposition - { - List results = []; - - foreach (T contentType in contentTypes) - { - List properties = []; - - foreach (IPropertyType propertyType in contentType.CompositionPropertyTypes) - { - properties.Add(new PropertyInputSchema - { - Alias = propertyType.Alias, - DataTypeKey = propertyType.DataTypeKey, - EditorAlias = propertyType.PropertyEditorAlias, - Mandatory = propertyType.Mandatory, - Variations = propertyType.Variations, - }); - } - - results.Add(new ContentTypeInputSchema - { - Key = contentType.Key, - Alias = contentType.Alias, - Properties = properties, - IsElement = contentType.IsElement, - Variations = contentType.Variations, - }); - } - - return results; - } -} diff --git a/src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs b/src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs deleted file mode 100644 index de6050b5a877..000000000000 --- a/src/Umbraco.Core/Services/IContentTypeInputSchemaService.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Umbraco.Cms.Core.Models; - -namespace Umbraco.Cms.Core.Services; - -/// -/// Service to get content type input schema information for programmatic content creation. -/// -/// -/// -/// This service provides the minimal schema information needed to input content values, -/// including property aliases, data type keys, and variation settings. -/// -/// -/// Property groups and inheritance information are not included as they are not relevant for input. -/// -/// -public interface IContentTypeInputSchemaService -{ - /// - /// Gets input schemas for specific document types by their keys. - /// - /// The unique identifiers of the document types to retrieve. - /// - /// A collection containing the schemas for document types that were found. - /// Returns an empty collection if none of the specified keys were found. - /// - Task> GetDocumentTypeSchemasAsync(IEnumerable keys); - - /// - /// Gets input schemas for specific media types by their keys. - /// - /// The unique identifiers of the media types to retrieve. - /// - /// A collection containing the schemas for media types that were found. - /// Returns an empty collection if none of the specified keys were found. - /// - Task> GetMediaTypeSchemasAsync(IEnumerable keys); - - /// - /// Gets input schemas for specific member types by their keys. - /// - /// The unique identifiers of the member types to retrieve. - /// - /// A collection containing the schemas for member types that were found. - /// Returns an empty collection if none of the specified keys were found. - /// - Task> GetMemberTypeSchemasAsync(IEnumerable keys); -} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs deleted file mode 100644 index 6e973304ea5a..000000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentTypeInputSchemaServiceTests.cs +++ /dev/null @@ -1,213 +0,0 @@ -using Moq; -using NUnit.Framework; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; - -[TestFixture] -public class ContentTypeInputSchemaServiceTests -{ - private Mock _contentTypeServiceMock = null!; - private Mock _mediaTypeServiceMock = null!; - private Mock _memberTypeServiceMock = null!; - private ContentTypeInputSchemaService _sut = null!; - - [SetUp] - public void SetUp() - { - _contentTypeServiceMock = new Mock(); - _mediaTypeServiceMock = new Mock(); - _memberTypeServiceMock = new Mock(); - - _sut = new ContentTypeInputSchemaService( - _contentTypeServiceMock.Object, - _mediaTypeServiceMock.Object, - _memberTypeServiceMock.Object); - } - - [Test] - public async Task GetDocumentTypeSchemasAsync_ReturnsRequestedTypes() - { - // Arrange - var key = Guid.NewGuid(); - var contentType = CreateMockContentType(key, "testType"); - _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); - - // Act - var result = await _sut.GetDocumentTypeSchemasAsync([key]); - - // Assert - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result.First().Alias, Is.EqualTo("testType")); - Assert.That(result.First().Key, Is.EqualTo(key)); - } - - [Test] - public async Task GetDocumentTypeSchemasAsync_ReturnsEmptyWhenNoTypesFound() - { - // Arrange - _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns(Array.Empty()); - - // Act - var result = await _sut.GetDocumentTypeSchemasAsync([Guid.NewGuid()]); - - // Assert - Assert.That(result, Is.Empty); - } - - [Test] - public async Task GetDocumentTypeSchemasAsync_ReturnsOnlyFoundTypes() - { - // Arrange - var foundKey = Guid.NewGuid(); - var notFoundKey = Guid.NewGuid(); - var contentType = CreateMockContentType(foundKey, "foundType"); - _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); - - // Act - var result = await _sut.GetDocumentTypeSchemasAsync([foundKey, notFoundKey]); - - // Assert - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result.First().Key, Is.EqualTo(foundKey)); - } - - [Test] - public async Task GetDocumentTypeSchemasAsync_IncludesAllProperties() - { - // Arrange - var key = Guid.NewGuid(); - var dataTypeKey = Guid.NewGuid(); - var propertyType = Mock.Of(p => - p.Alias == "testProperty" && - p.DataTypeKey == dataTypeKey && - p.PropertyEditorAlias == "Umbraco.TextBox" && - p.Mandatory == true && - p.Variations == ContentVariation.Culture); - - var contentType = Mock.Of(x => - x.Key == key && - x.Alias == "testType" && - x.CompositionPropertyTypes == new[] { propertyType } && - x.CompositionKeys() == Array.Empty() && - x.IsElement == false && - x.Variations == ContentVariation.Nothing); - - _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); - - // Act - var result = await _sut.GetDocumentTypeSchemasAsync([key]); - - // Assert - var schema = result.First(); - Assert.That(schema.Properties, Has.Count.EqualTo(1)); - var prop = schema.Properties.First(); - Assert.That(prop.Alias, Is.EqualTo("testProperty")); - Assert.That(prop.DataTypeKey, Is.EqualTo(dataTypeKey)); - Assert.That(prop.EditorAlias, Is.EqualTo("Umbraco.TextBox")); - Assert.That(prop.Mandatory, Is.True); - Assert.That(prop.Variations, Is.EqualTo(ContentVariation.Culture)); - } - - [Test] - public async Task GetDocumentTypeSchemasAsync_CorrectlySetsIsElement() - { - // Arrange - var documentKey = Guid.NewGuid(); - var elementKey = Guid.NewGuid(); - - var documentType = CreateMockContentType(documentKey, "documentType", isElement: false); - var elementType = CreateMockContentType(elementKey, "elementType", isElement: true); - - _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([documentType, elementType]); - - // Act - var result = await _sut.GetDocumentTypeSchemasAsync([documentKey, elementKey]); - - // Assert - Assert.That(result.First(x => x.Alias == "documentType").IsElement, Is.False); - Assert.That(result.First(x => x.Alias == "elementType").IsElement, Is.True); - } - - [Test] - public async Task GetDocumentTypeSchemasAsync_CorrectlySetsVariations() - { - // Arrange - var key = Guid.NewGuid(); - var contentType = Mock.Of(x => - x.Key == key && - x.Alias == "variantType" && - x.CompositionPropertyTypes == Array.Empty() && - x.CompositionKeys() == Array.Empty() && - x.IsElement == false && - x.Variations == ContentVariation.CultureAndSegment); - - _contentTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([contentType]); - - // Act - var result = await _sut.GetDocumentTypeSchemasAsync([key]); - - // Assert - Assert.That(result.First().Variations, Is.EqualTo(ContentVariation.CultureAndSegment)); - } - - [Test] - public async Task GetMediaTypeSchemasAsync_ReturnsRequestedTypes() - { - // Arrange - var key = Guid.NewGuid(); - var mediaType = CreateMockMediaType(key, "testMediaType"); - _mediaTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([mediaType]); - - // Act - var result = await _sut.GetMediaTypeSchemasAsync([key]); - - // Assert - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result.First().Alias, Is.EqualTo("testMediaType")); - } - - [Test] - public async Task GetMemberTypeSchemasAsync_ReturnsRequestedTypes() - { - // Arrange - var key = Guid.NewGuid(); - var memberType = CreateMockMemberType(key, "testMemberType"); - _memberTypeServiceMock.Setup(x => x.GetMany(It.IsAny())).Returns([memberType]); - - // Act - var result = await _sut.GetMemberTypeSchemasAsync([key]); - - // Assert - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result.First().Alias, Is.EqualTo("testMemberType")); - } - - private static IContentType CreateMockContentType(Guid key, string alias, bool isElement = false) - => Mock.Of(x => - x.Key == key && - x.Alias == alias && - x.CompositionPropertyTypes == Array.Empty() && - x.CompositionKeys() == Array.Empty() && - x.IsElement == isElement && - x.Variations == ContentVariation.Nothing); - - private static IMediaType CreateMockMediaType(Guid key, string alias) - => Mock.Of(x => - x.Key == key && - x.Alias == alias && - x.CompositionPropertyTypes == Array.Empty() && - x.CompositionKeys() == Array.Empty() && - x.IsElement == false && - x.Variations == ContentVariation.Nothing); - - private static IMemberType CreateMockMemberType(Guid key, string alias) - => Mock.Of(x => - x.Key == key && - x.Alias == alias && - x.CompositionPropertyTypes == Array.Empty() && - x.CompositionKeys() == Array.Empty() && - x.IsElement == false && - x.Variations == ContentVariation.Nothing); -} From c206e73e25d0f48057621ed74a7dca58e6bc9e14 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 16 Feb 2026 08:34:24 +0100 Subject: [PATCH 11/24] Fix block limit on blocklist and grid --- .../BlockGridPropertyEditorBase.cs | 36 +++++++++++++++++-- .../BlockListPropertyEditorBase.cs | 26 +++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index def5f2850759..afdb91a106d8 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -104,6 +104,38 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac }, }; + // Build content data schema with allowed content type constraints + var contentDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); + if (config?.Blocks is { Length: > 0 }) + { + var allowedContentTypes = new JsonArray(); + foreach (var block in config.Blocks) + { + allowedContentTypes.Add(JsonValue.Create(block.ContentElementTypeKey.ToString())); + } + + contentDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedContentTypes; + } + + // Build settings data schema with allowed settings type constraints + var settingsDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); + if (config?.Blocks is { Length: > 0 }) + { + var allowedSettingsTypes = new JsonArray(); + foreach (var block in config.Blocks) + { + if (block.SettingsElementTypeKey.HasValue) + { + allowedSettingsTypes.Add(JsonValue.Create(block.SettingsElementTypeKey.Value.ToString())); + } + } + + if (allowedSettingsTypes.Count > 0) + { + settingsDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedSettingsTypes; + } + } + // Build the main schema var schema = new JsonObject { @@ -130,12 +162,12 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["contentData"] = new JsonObject { ["type"] = "array", - ["items"] = blockItemDataSchema, + ["items"] = contentDataItemSchema, }, ["settingsData"] = new JsonObject { ["type"] = "array", - ["items"] = blockItemDataSchema.DeepClone(), + ["items"] = settingsDataItemSchema, }, }, }; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index 15b872822f24..b15f3c7ccd90 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -96,20 +96,36 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac }, }; - // Add contentTypeKey constraints if blocks are configured + // Build content data schema with allowed content type constraints + var contentDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); if (config?.Blocks is { Length: > 0 }) { var allowedContentTypes = new JsonArray(); - var allowedSettingsTypes = new JsonArray(); - foreach (var block in config.Blocks) { allowedContentTypes.Add(JsonValue.Create(block.ContentElementTypeKey.ToString())); + } + + contentDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedContentTypes; + } + + // Build settings data schema with allowed settings type constraints + var settingsDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); + if (config?.Blocks is { Length: > 0 }) + { + var allowedSettingsTypes = new JsonArray(); + foreach (var block in config.Blocks) + { if (block.SettingsElementTypeKey.HasValue) { allowedSettingsTypes.Add(JsonValue.Create(block.SettingsElementTypeKey.Value.ToString())); } } + + if (allowedSettingsTypes.Count > 0) + { + settingsDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedSettingsTypes; + } } // Build the main schema @@ -134,12 +150,12 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac ["contentData"] = new JsonObject { ["type"] = "array", - ["items"] = blockItemDataSchema, + ["items"] = contentDataItemSchema, }, ["settingsData"] = new JsonObject { ["type"] = "array", - ["items"] = blockItemDataSchema.DeepClone(), + ["items"] = settingsDataItemSchema, }, ["expose"] = new JsonObject { From 15099800422db661d76c7e016f1daf9fffef63cc Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 16 Feb 2026 12:59:37 +0100 Subject: [PATCH 12/24] add datatype schema batch --- .../DataType/SchemasDataTypeController.cs | 71 +++++++++++++++++++ .../DataTypeSchemaItemResponseModel.cs | 30 ++++++++ .../ViewModels/FetchResponseModel.cs | 18 +++++ 3 files changed, 119 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaItemResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/FetchResponseModel.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs new file mode 100644 index 000000000000..3b3093a11c7e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs @@ -0,0 +1,71 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.DataType; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.DataType; + +/// +/// Controller for retrieving multiple data type value schemas in a single request. +/// +[ApiVersion("1.0")] +public class SchemasDataTypeController : DataTypeControllerBase +{ + private readonly IPropertyEditorSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + /// The property editor schema service. + public SchemasDataTypeController(IPropertyEditorSchemaService schemaService) + => _schemaService = schemaService; + + /// + /// Gets the value schemas for multiple data types. + /// + /// A cancellation token. + /// The unique identifiers of the data types. + /// The schema information for the requested data types. + /// + /// Returns schema information for property editors that implement IValueSchemaProvider. + /// Each item includes an error field if the schema could not be retrieved (e.g., data type not found or schema not supported). + /// + [HttpGet("schema")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(FetchResponseModel), StatusCodes.Status200OK)] + public async Task GetSchemas( + CancellationToken cancellationToken, + [FromQuery(Name = "id")] Guid[] ids) + { + Guid[] requestedIds = [.. ids.Distinct()]; + + if (requestedIds.Length == 0) + { + return Ok(new FetchResponseModel()); + } + + var items = new List(); + + foreach (Guid id in requestedIds) + { + Attempt attempt = await _schemaService.GetSchemaAsync(id); + items.Add(new DataTypeSchemaItemResponseModel + { + Id = id, + ValueTypeName = attempt.Success ? attempt.Result.ValueType?.FullName : null, + JsonSchema = attempt.Success ? attempt.Result.JsonSchema : null, + Error = attempt.Success ? null : attempt.Status.ToString(), + }); + } + + return Ok(new FetchResponseModel + { + Total = items.Count, + Items = items, + }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaItemResponseModel.cs new file mode 100644 index 000000000000..1a72bff81a16 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeSchemaItemResponseModel.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Api.Management.ViewModels.DataType; + +/// +/// Represents the schema information for a single data type in a batch response. +/// +public class DataTypeSchemaItemResponseModel +{ + /// + /// Gets or sets the unique identifier of the data type. + /// + public required Guid Id { get; set; } + + /// + /// Gets or sets the full name of the CLR type for property values, if available. + /// + public string? ValueTypeName { get; set; } + + /// + /// Gets or sets the JSON Schema for property values, if available. + /// + public JsonObject? JsonSchema { get; set; } + + /// + /// Gets or sets the error status if the schema could not be retrieved. + /// Possible values: "DataTypeNotFound", "SchemaNotSupported", or null if successful. + /// + public string? Error { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/FetchResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/FetchResponseModel.cs new file mode 100644 index 000000000000..27e2d7ca5c9b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/FetchResponseModel.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Cms.Api.Management.ViewModels; + +/// +/// Represents a response model for fetching multiple entities, including the total count and the collection of items. +/// +/// The entity response model type. +public class FetchResponseModel +{ + /// + /// Gets or sets the total number of entities returned. + /// + public int Total { get; set; } + + /// + /// Gets or sets the collection of fetched entities. + /// + public IEnumerable Items { get; set; } = []; +} From a2d019ac9d8d1ccd2ff11666f459e50871fa7d72 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 16 Feb 2026 14:39:23 +0100 Subject: [PATCH 13/24] Refactoring blocks json schema generation and add to richtext --- .../BlockGridPropertyEditorBase.cs | 146 ++++---------- .../PropertyEditors/BlockJsonSchemaHelper.cs | 188 ++++++++++++++++++ .../BlockListPropertyEditorBase.cs | 145 +++----------- .../PropertyEditors/RichTextPropertyEditor.cs | 73 +++++-- 4 files changed, 312 insertions(+), 240 deletions(-) create mode 100644 src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index afdb91a106d8..5da4d046d6c9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -14,6 +14,7 @@ using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.PropertyEditors; using Umbraco.Extensions; using BlockGridAreaConfiguration = Umbraco.Cms.Core.PropertyEditors.BlockGridConfiguration.BlockGridAreaConfiguration; @@ -43,7 +44,7 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac { var config = configuration as BlockGridConfiguration; - // Build area item schema + // Build area item schema (BlockGrid-specific) var areaItemSchema = new JsonObject { ["type"] = "object", @@ -58,138 +59,61 @@ protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac }, }; - // Build layout item schema (with grid-specific properties) - var layoutItemSchema = new JsonObject + // Build layout item schema (BlockGrid-specific - with columnSpan, rowSpan, areas) + JsonObject layoutItemSchema = BlockJsonSchemaHelper.CreateBaseLayoutItemSchema(); + layoutItemSchema["properties"]!.AsObject()["columnSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }; + layoutItemSchema["properties"]!.AsObject()["rowSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }; + layoutItemSchema["properties"]!.AsObject()["areas"] = new JsonObject { - ["type"] = "object", - ["required"] = new JsonArray("contentKey"), - ["properties"] = new JsonObject - { - ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["columnSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, - ["rowSpan"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, - ["areas"] = new JsonObject - { - ["type"] = "array", - ["items"] = areaItemSchema, - }, - }, + ["type"] = "array", + ["items"] = areaItemSchema, }; - // Build block item data schema - var blockItemDataSchema = new JsonObject + // Build content and settings data schemas with constraints + JsonObject contentDataItemSchema = BlockJsonSchemaHelper.CreateContentDataSchema(config?.Blocks); + JsonObject settingsDataItemSchema = BlockJsonSchemaHelper.CreateSettingsDataSchema(config?.Blocks); + + // Build the main schema + JsonObject schema = BlockJsonSchemaHelper.CreateRootSchema(); + schema["$defs"] = new JsonObject { - ["type"] = "object", - ["required"] = new JsonArray("key", "contentTypeKey"), - ["properties"] = new JsonObject + ["layoutItem"] = layoutItemSchema, + }; + schema["properties"] = new JsonObject + { + ["layout"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["values"] = new JsonObject + ["type"] = "object", + ["properties"] = new JsonObject { - ["type"] = "array", - ["items"] = new JsonObject + [Constants.PropertyEditors.Aliases.BlockGrid] = new JsonObject { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["alias"] = new JsonObject { ["type"] = "string" }, - ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, - ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, - ["value"] = new JsonObject { }, // Any type - depends on property editor - }, + ["type"] = "array", + ["items"] = new JsonObject { ["$ref"] = "#/$defs/layoutItem" }, }, }, }, - }; - - // Build content data schema with allowed content type constraints - var contentDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); - if (config?.Blocks is { Length: > 0 }) - { - var allowedContentTypes = new JsonArray(); - foreach (var block in config.Blocks) + ["contentData"] = new JsonObject { - allowedContentTypes.Add(JsonValue.Create(block.ContentElementTypeKey.ToString())); - } - - contentDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedContentTypes; - } - - // Build settings data schema with allowed settings type constraints - var settingsDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); - if (config?.Blocks is { Length: > 0 }) - { - var allowedSettingsTypes = new JsonArray(); - foreach (var block in config.Blocks) - { - if (block.SettingsElementTypeKey.HasValue) - { - allowedSettingsTypes.Add(JsonValue.Create(block.SettingsElementTypeKey.Value.ToString())); - } - } - - if (allowedSettingsTypes.Count > 0) - { - settingsDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedSettingsTypes; - } - } - - // Build the main schema - var schema = new JsonObject - { - ["$schema"] = "https://json-schema.org/draft/2020-12/schema", - ["type"] = new JsonArray("object", "null"), - ["$defs"] = new JsonObject - { - ["layoutItem"] = layoutItemSchema, + ["type"] = "array", + ["items"] = contentDataItemSchema, }, - ["properties"] = new JsonObject + ["settingsData"] = new JsonObject { - ["layout"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - [Constants.PropertyEditors.Aliases.BlockGrid] = new JsonObject - { - ["type"] = "array", - ["items"] = new JsonObject { ["$ref"] = "#/$defs/layoutItem" }, - }, - }, - }, - ["contentData"] = new JsonObject - { - ["type"] = "array", - ["items"] = contentDataItemSchema, - }, - ["settingsData"] = new JsonObject - { - ["type"] = "array", - ["items"] = settingsDataItemSchema, - }, + ["type"] = "array", + ["items"] = settingsDataItemSchema, }, }; - // Add grid columns constraint from configuration + // Add grid columns constraint from configuration (BlockGrid-specific) if (config?.GridColumns is int gridColumns && gridColumns > 0) { layoutItemSchema["properties"]!["columnSpan"]!.AsObject()["maximum"] = gridColumns; } // Add validation constraints - if (config?.ValidationLimit?.Min is int min && min > 0) - { - var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockGrid]!.AsObject(); - layoutArray["minItems"] = min; - } - - if (config?.ValidationLimit?.Max is int max && max > 0) - { - var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockGrid]!.AsObject(); - layoutArray["maxItems"] = max; - } + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockGrid]!.AsObject(); + BlockJsonSchemaHelper.ApplyValidationConstraints(layoutArray, config?.ValidationLimit?.Min, config?.ValidationLimit?.Max); return schema; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs new file mode 100644 index 000000000000..c2c7c48df1d1 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs @@ -0,0 +1,188 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors; + +/// +/// Helper class for building JSON schemas for block-based property editors. +/// +public static class BlockJsonSchemaHelper +{ + /// + /// Creates the base block item data schema used by all block editors. + /// Contains key, contentTypeKey, and values array structure. + /// + /// A JsonObject representing the block item data schema. + public static JsonObject CreateBlockItemDataSchema() => + new() + { + ["type"] = "object", + ["required"] = new JsonArray("key", "contentTypeKey"), + ["properties"] = new JsonObject + { + ["key"] = new JsonObject + { + ["type"] = "string", + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + }, + ["contentTypeKey"] = new JsonObject + { + ["type"] = "string", + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + }, + ["values"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["alias"] = new JsonObject { ["type"] = "string" }, + ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["value"] = new JsonObject { }, // Any type - depends on property editor + }, + }, + }, + }, + }; + + /// + /// Creates a content data schema with allowed content type constraints from block configuration. + /// + /// The block configurations containing content element type keys. + /// A JsonObject representing the content data item schema with type constraints. + public static JsonObject CreateContentDataSchema(ICollection? blocks) + { + JsonObject schema = CreateBlockItemDataSchema(); + + if (blocks is null || blocks.Count == 0) + { + return schema; + } + + var allowedContentTypes = new JsonArray(); + foreach (IBlockConfiguration block in blocks) + { + allowedContentTypes.Add(JsonValue.Create(block.ContentElementTypeKey.ToString())); + } + + schema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedContentTypes; + return schema; + } + + /// + /// Creates a settings data schema with allowed settings type constraints from block configuration. + /// + /// The block configurations containing settings element type keys. + /// A JsonObject representing the settings data item schema with type constraints. + public static JsonObject CreateSettingsDataSchema(ICollection? blocks) + { + JsonObject schema = CreateBlockItemDataSchema(); + + if (blocks is null || blocks.Count == 0) + { + return schema; + } + + var allowedSettingsTypes = new JsonArray(); + foreach (IBlockConfiguration block in blocks) + { + if (block.SettingsElementTypeKey.HasValue) + { + allowedSettingsTypes.Add(JsonValue.Create(block.SettingsElementTypeKey.Value.ToString())); + } + } + + if (allowedSettingsTypes.Count > 0) + { + schema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedSettingsTypes; + } + + return schema; + } + + /// + /// Creates the base layout item schema with contentKey and settingsKey. + /// Used as the foundation for all block layout items. + /// + /// A JsonObject representing the base layout item schema. + public static JsonObject CreateBaseLayoutItemSchema() => + new() + { + ["type"] = "object", + ["required"] = new JsonArray("contentKey"), + ["properties"] = new JsonObject + { + ["contentKey"] = new JsonObject + { + ["type"] = "string", + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + }, + ["settingsKey"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + }, + }, + }; + + /// + /// Creates the expose item schema for block variation. + /// + /// A JsonObject representing the expose item schema. + public static JsonObject CreateExposeItemSchema() => + new() + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["contentKey"] = new JsonObject + { + ["type"] = "string", + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + }, + ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + }, + }; + + /// + /// Applies minItems/maxItems validation constraints to a layout array schema. + /// + /// The layout array schema to modify. + /// Optional minimum number of items. + /// Optional maximum number of items. + public static void ApplyValidationConstraints(JsonObject layoutArraySchema, int? min, int? max) + { + if (min is int minValue && minValue > 0) + { + layoutArraySchema["minItems"] = minValue; + } + + if (max is int maxValue && maxValue > 0) + { + layoutArraySchema["maxItems"] = maxValue; + } + } + + /// + /// Creates the root schema wrapper with $schema and nullable object type. + /// + /// A JsonObject with the base schema structure. + public static JsonObject CreateRootSchema() => + new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + }; +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index b15f3c7ccd90..51bc6ba6f2a1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.PropertyEditors; using Umbraco.Cms.Infrastructure.PropertyEditors.Validators; namespace Umbraco.Cms.Core.PropertyEditors; @@ -46,137 +47,51 @@ protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFac var config = configuration as BlockListConfiguration; // Build layout item schema - var layoutItemSchema = new JsonObject - { - ["type"] = "object", - ["required"] = new JsonArray("contentKey"), - ["properties"] = new JsonObject - { - ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["settingsKey"] = new JsonObject { ["type"] = new JsonArray("string", "null"), ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - }, - }; + JsonObject layoutItemSchema = BlockJsonSchemaHelper.CreateBaseLayoutItemSchema(); + + // Build content and settings data schemas with constraints + JsonObject contentDataItemSchema = BlockJsonSchemaHelper.CreateContentDataSchema(config?.Blocks); + JsonObject settingsDataItemSchema = BlockJsonSchemaHelper.CreateSettingsDataSchema(config?.Blocks); + + // Build expose schema (BlockList-specific) + JsonObject exposeItemSchema = BlockJsonSchemaHelper.CreateExposeItemSchema(); - // Build block item data schema - var blockItemDataSchema = new JsonObject + // Build the main schema + JsonObject schema = BlockJsonSchemaHelper.CreateRootSchema(); + schema["properties"] = new JsonObject { - ["type"] = "object", - ["required"] = new JsonArray("key", "contentTypeKey"), - ["properties"] = new JsonObject + ["layout"] = new JsonObject { - ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["contentTypeKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["values"] = new JsonObject + ["type"] = "object", + ["properties"] = new JsonObject { - ["type"] = "array", - ["items"] = new JsonObject + [Constants.PropertyEditors.Aliases.BlockList] = new JsonObject { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["alias"] = new JsonObject { ["type"] = "string" }, - ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, - ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, - ["value"] = new JsonObject { }, // Any type - depends on property editor - }, + ["type"] = "array", + ["items"] = layoutItemSchema, }, }, }, - }; - - // Build expose schema (BlockItemVariation) - var exposeItemSchema = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject + ["contentData"] = new JsonObject { - ["contentKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, - ["culture"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, - ["segment"] = new JsonObject { ["type"] = new JsonArray("string", "null") }, + ["type"] = "array", + ["items"] = contentDataItemSchema, }, - }; - - // Build content data schema with allowed content type constraints - var contentDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); - if (config?.Blocks is { Length: > 0 }) - { - var allowedContentTypes = new JsonArray(); - foreach (var block in config.Blocks) + ["settingsData"] = new JsonObject { - allowedContentTypes.Add(JsonValue.Create(block.ContentElementTypeKey.ToString())); - } - - contentDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedContentTypes; - } - - // Build settings data schema with allowed settings type constraints - var settingsDataItemSchema = blockItemDataSchema.DeepClone().AsObject(); - if (config?.Blocks is { Length: > 0 }) - { - var allowedSettingsTypes = new JsonArray(); - foreach (var block in config.Blocks) - { - if (block.SettingsElementTypeKey.HasValue) - { - allowedSettingsTypes.Add(JsonValue.Create(block.SettingsElementTypeKey.Value.ToString())); - } - } - - if (allowedSettingsTypes.Count > 0) - { - settingsDataItemSchema["properties"]!["contentTypeKey"]!.AsObject()["enum"] = allowedSettingsTypes; - } - } - - // Build the main schema - var schema = new JsonObject - { - ["$schema"] = "https://json-schema.org/draft/2020-12/schema", - ["type"] = new JsonArray("object", "null"), - ["properties"] = new JsonObject + ["type"] = "array", + ["items"] = settingsDataItemSchema, + }, + ["expose"] = new JsonObject { - ["layout"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - [Constants.PropertyEditors.Aliases.BlockList] = new JsonObject - { - ["type"] = "array", - ["items"] = layoutItemSchema, - }, - }, - }, - ["contentData"] = new JsonObject - { - ["type"] = "array", - ["items"] = contentDataItemSchema, - }, - ["settingsData"] = new JsonObject - { - ["type"] = "array", - ["items"] = settingsDataItemSchema, - }, - ["expose"] = new JsonObject - { - ["type"] = "array", - ["items"] = exposeItemSchema, - }, + ["type"] = "array", + ["items"] = exposeItemSchema, }, }; // Add validation constraints - if (config?.ValidationLimit.Min is int min && min > 0) - { - var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockList]!.AsObject(); - layoutArray["minItems"] = min; - } - - if (config?.ValidationLimit.Max is int max && max > 0) - { - var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockList]!.AsObject(); - layoutArray["maxItems"] = max; - } + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockList]!.AsObject(); + BlockJsonSchemaHelper.ApplyValidationConstraints(layoutArray, config?.ValidationLimit.Min, config?.ValidationLimit.Max); return schema; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index c0be2916face..6d2065a7ba62 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -18,6 +18,7 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Infrastructure.Extensions; +using Umbraco.Cms.Infrastructure.PropertyEditors; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -60,26 +61,70 @@ public RichTextPropertyEditor( public Type? GetValueType(object? configuration) => typeof(RichTextEditorValue); /// - public JsonObject? GetValueSchema(object? configuration) => new() + public JsonObject? GetValueSchema(object? configuration) { - ["$schema"] = "https://json-schema.org/draft/2020-12/schema", - ["type"] = new JsonArray("object", "null"), - ["properties"] = new JsonObject + var config = configuration as RichTextConfiguration; + + // Build schemas using helper + JsonObject layoutItemSchema = BlockJsonSchemaHelper.CreateBaseLayoutItemSchema(); + JsonObject contentDataItemSchema = BlockJsonSchemaHelper.CreateContentDataSchema(config?.Blocks); + JsonObject settingsDataItemSchema = BlockJsonSchemaHelper.CreateSettingsDataSchema(config?.Blocks); + JsonObject exposeItemSchema = BlockJsonSchemaHelper.CreateExposeItemSchema(); + + // Build blocks schema + var blocksSchema = new JsonObject { - ["markup"] = new JsonObject + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject { - ["type"] = "string", - ["description"] = "HTML markup content", + ["layout"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + [Constants.PropertyEditors.Aliases.RichText] = new JsonObject + { + ["type"] = "array", + ["items"] = layoutItemSchema, + }, + }, + }, + ["contentData"] = new JsonObject + { + ["type"] = "array", + ["items"] = contentDataItemSchema, + }, + ["settingsData"] = new JsonObject + { + ["type"] = "array", + ["items"] = settingsDataItemSchema, + }, + ["expose"] = new JsonObject + { + ["type"] = "array", + ["items"] = exposeItemSchema, + }, }, - ["blocks"] = new JsonObject + }; + + // Build main schema + return new JsonObject + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject { - ["type"] = new JsonArray("object", "null"), - ["description"] = "Block editor data (configuration-dependent structure)", + ["markup"] = new JsonObject + { + ["type"] = "string", + ["description"] = "HTML markup content", + }, + ["blocks"] = blocksSchema, }, - }, - ["required"] = new JsonArray("markup"), - ["description"] = "Rich text editor value with HTML markup and optional blocks", - }; + ["required"] = new JsonArray("markup"), + ["description"] = "Rich text editor value with HTML markup and optional blocks", + }; + } /// public override bool CanMergePartialPropertyValues(IPropertyType propertyType) => propertyType.VariesByCulture() is false; From 7e2394fcb24a48983ec7bd516fab40f8f660d5e1 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 17 Feb 2026 15:02:42 +0100 Subject: [PATCH 14/24] Package version update and more tests! --- Directory.Packages.props | 2 +- .../ValueSchemaProviderTests.cs | 23 -- .../PropertyEditorSchemaServiceTests.cs | 5 +- ...ropertyEditorSchemaServiceTests_pickers.cs | 299 ++++++++++++++++++ 4 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d5ee0d7f8dc6..a2644daef909 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -49,7 +49,7 @@ - + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs index 7ccfa071e70a..33f0f9f60fce 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs @@ -101,29 +101,6 @@ public void IntegerPropertyEditor_Omits_Step_When_One() Assert.That(schema!.ContainsKey("multipleOf"), Is.False); } - [Test] - public void ContentPickerPropertyEditor_Returns_String_Schema_With_UDI_Pattern() - { - // Arrange - var editor = CreateContentPickerPropertyEditor(); - - // Act - var schema = editor.GetValueSchema(null); - - // Assert - Assert.That(schema, Is.Not.Null); - Assert.That(schema!["$schema"]?.GetValue(), Is.EqualTo("https://json-schema.org/draft/2020-12/schema")); - - var typeArray = schema["type"] as JsonArray; - Assert.That(typeArray, Is.Not.Null); - Assert.That(typeArray!.Select(t => t?.GetValue()), Is.EquivalentTo(new[] { "string", "null" })); - - var pattern = schema["pattern"]?.GetValue(); - Assert.That(pattern, Is.Not.Null); - // The pattern uses escaped forward slashes in the regex - Assert.That(pattern, Contains.Substring("umb:").And.Contains("document")); - } - [Test] public void ContentPickerPropertyEditor_Returns_ValueType_String() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs index e16076d8bf77..ec8ffa50167d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -2,16 +2,19 @@ using Moq; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.Services.Implement; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services; [TestFixture] -public class PropertyEditorSchemaServiceTests +public partial class PropertyEditorSchemaServiceTests { private Mock _dataTypeServiceMock = null!; private List _dataEditors = null!; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs new file mode 100644 index 000000000000..8a64849604c2 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs @@ -0,0 +1,299 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services; + +// tests that correct schema's are being returned by validating values against the schema for guids that support guids +public partial class PropertyEditorSchemaServiceTests +{ + #region Simple UUID Pickers (ContentPicker, MemberPicker, UserPicker) + + [TestCase("\"550e8400-e29b-41d4-a716-446655440000\"", true, TestName = "ValidGuidWithDashes_Succeeds")] + [TestCase("\"550e8400e29b41d4a716446655440000\"", true, TestName = "ValidGuidNoDashes_Succeeds")] + [TestCase("\"55xyz000-e29b-41d4-a716-446655440000\"", false, TestName = "InvalidGuid_Fails")] + [TestCase("\"818\"", false, TestName = "NumberAsString_Fails")] + public async Task ValidateValueAsync_ContentPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var contentPickerEditor = CreateContentPickerPropertyEditor(); + SetupDataEditors(contentPickerEditor); + SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.ContentPicker); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + + if (shouldBeValid) + { + Assert.That(result.Result, Is.Empty, "Expected no validation errors for a valid GUID"); + } + else + { + Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for an invalid GUID"); + Assert.That(result.Result.First().Keyword, Is.EqualTo("pattern")); + } + } + + [TestCase("\"550e8400-e29b-41d4-a716-446655440000\"", true, TestName = "ValidGuidWithDashes_Succeeds")] + [TestCase("\"550e8400e29b41d4a716446655440000\"", true, TestName = "ValidGuidNoDashes_Succeeds")] + [TestCase("\"55xyz000-e29b-41d4-a716-446655440000\"", false, TestName = "InvalidGuid_Fails")] + [TestCase("\"818\"", false, TestName = "NumberAsString_Fails")] + public async Task ValidateValueAsync_MemberPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var memberPickerEditor = CreateMemberPickerPropertyEditor(); + SetupDataEditors(memberPickerEditor); + SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MemberPicker); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + + if (shouldBeValid) + { + Assert.That(result.Result, Is.Empty, "Expected no validation errors for a valid GUID"); + } + else + { + Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for an invalid GUID"); + Assert.That(result.Result.First().Keyword, Is.EqualTo("pattern")); + } + } + + [TestCase("\"550e8400-e29b-41d4-a716-446655440000\"", true, TestName = "ValidGuidWithDashes_Succeeds")] + [TestCase("\"550e8400e29b41d4a716446655440000\"", true, TestName = "ValidGuidNoDashes_Succeeds")] + [TestCase("\"55xyz000-e29b-41d4-a716-446655440000\"", false, TestName = "InvalidGuid_Fails")] + [TestCase("\"818\"", false, TestName = "NumberAsString_Fails")] + public async Task ValidateValueAsync_UserPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var userPickerEditor = CreateUserPickerPropertyEditor(); + SetupDataEditors(userPickerEditor); + SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.UserPicker); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + + if (shouldBeValid) + { + Assert.That(result.Result, Is.Empty, "Expected no validation errors for a valid GUID"); + } + else + { + Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for an invalid GUID"); + Assert.That(result.Result.First().Keyword, Is.EqualTo("pattern")); + } + } + + #endregion + + #region MultiNodeTreePicker + + [TestCase("null", true, TestName = "Null_Succeeds")] + [TestCase("[]", true, TestName = "EmptyArray_Succeeds")] + [TestCase("[{\"type\":\"content\",\"unique\":\"550e8400-e29b-41d4-a716-446655440000\"}]", true, TestName = "ValidGuidWithDashes_Succeeds")] + [TestCase("[{\"type\":\"content\",\"unique\":\"550e8400e29b41d4a716446655440000\"}]", true, TestName = "ValidGuidNoDashes_Succeeds")] + [TestCase("[{\"type\":\"content\",\"unique\":\"invalid-guid\"}]", false, TestName = "InvalidGuid_Fails")] + [TestCase("[{\"type\":\"content\",\"unique\":\"818\"}]", false, TestName = "NumberAsString_Fails")] + [TestCase("[{\"type\":\"media\",\"unique\":\"550e8400-e29b-41d4-a716-446655440000\"},{\"type\":\"content\",\"unique\":\"660e8400-e29b-41d4-a716-446655440000\"}]", true, TestName = "MultipleValidItems_Succeeds")] + public async Task ValidateValueAsync_MultiNodeTreePicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var multiNodeTreePickerEditor = CreateMultiNodeTreePickerPropertyEditor(); + SetupDataEditors(multiNodeTreePickerEditor); + SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MultiNodeTreePicker); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + + if (shouldBeValid) + { + Assert.That(result.Result, Is.Empty, "Expected no validation errors for valid value"); + } + else + { + Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for invalid UUID value"); + } + } + + #endregion + + #region MediaPicker3 + + [TestCase("null", true, TestName = "Null_Succeeds")] + [TestCase("[]", true, TestName = "EmptyArray_Succeeds")] + [TestCase("[{\"key\":\"550e8400-e29b-41d4-a716-446655440000\",\"mediaKey\":\"660e8400-e29b-41d4-a716-446655440000\"}]", true, TestName = "ValidGuidsWithDashes_Succeeds")] + [TestCase("[{\"key\":\"550e8400e29b41d4a716446655440000\",\"mediaKey\":\"660e8400e29b41d4a716446655440000\"}]", true, TestName = "ValidGuidsNoDashes_Succeeds")] + [TestCase("[{\"key\":\"invalid-guid\",\"mediaKey\":\"660e8400-e29b-41d4-a716-446655440000\"}]", false, TestName = "InvalidKeyGuid_Fails")] + [TestCase("[{\"key\":\"550e8400-e29b-41d4-a716-446655440000\",\"mediaKey\":\"invalid-guid\"}]", false, TestName = "InvalidMediaKeyGuid_Fails")] + [TestCase("[{\"key\":\"818\",\"mediaKey\":\"660e8400-e29b-41d4-a716-446655440000\"}]", false, TestName = "NumberAsStringKey_Fails")] + public async Task ValidateValueAsync_MediaPicker3_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var mediaPicker3Editor = CreateMediaPicker3PropertyEditor(); + SetupDataEditors(mediaPicker3Editor); + SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MediaPicker3); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + + if (shouldBeValid) + { + Assert.That(result.Result, Is.Empty, "Expected no validation errors for valid value"); + } + else + { + Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for invalid UUID value"); + } + } + + #endregion + + #region MultiUrlPicker + + [TestCase("null", true, TestName = "Null_Succeeds")] + [TestCase("[]", true, TestName = "EmptyArray_Succeeds")] + [TestCase("[{\"unique\":\"550e8400-e29b-41d4-a716-446655440000\",\"type\":\"document\"}]", true, TestName = "ValidGuidWithDashes_Succeeds")] + [TestCase("[{\"unique\":\"550e8400e29b41d4a716446655440000\",\"type\":\"document\"}]", true, TestName = "ValidGuidNoDashes_Succeeds")] + [TestCase("[{\"unique\":null,\"type\":\"external\",\"url\":\"https://example.com\"}]", true, TestName = "NullUniqueForExternal_Succeeds")] + [TestCase("[{\"url\":\"https://example.com\",\"type\":\"external\"}]", true, TestName = "NoUniqueForExternal_Succeeds")] + [TestCase("[{\"unique\":\"invalid-guid\",\"type\":\"document\"}]", false, TestName = "InvalidGuid_Fails")] + [TestCase("[{\"unique\":\"818\",\"type\":\"document\"}]", false, TestName = "NumberAsString_Fails")] + public async Task ValidateValueAsync_MultiUrlPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var multiUrlPickerEditor = CreateMultiUrlPickerPropertyEditor(); + SetupDataEditors(multiUrlPickerEditor); + SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MultiUrlPicker); + + // Act + var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); + + if (shouldBeValid) + { + Assert.That(result.Result, Is.Empty, "Expected no validation errors for valid value"); + } + else + { + Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for invalid UUID value"); + } + } + + #endregion + + #region Picker Editor Factory Methods + + private static ContentPickerPropertyEditor CreateContentPickerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.ContentPicker))); + + return new ContentPickerPropertyEditor(dataValueEditorFactory, Mock.Of()); + } + + private static MemberPickerPropertyEditor CreateMemberPickerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.MemberPicker))); + + return new MemberPickerPropertyEditor(dataValueEditorFactory); + } + + private static UserPickerPropertyEditor CreateUserPickerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.UserPicker))); + + return new UserPickerPropertyEditor(dataValueEditorFactory); + } + + private static MultiNodeTreePickerPropertyEditor CreateMultiNodeTreePickerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.MultiNodeTreePicker))); + + return new MultiNodeTreePickerPropertyEditor(dataValueEditorFactory, Mock.Of()); + } + + private static MediaPicker3PropertyEditor CreateMediaPicker3PropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.MediaPicker3))); + + return new MediaPicker3PropertyEditor(dataValueEditorFactory, Mock.Of()); + } + + private static MultiUrlPickerPropertyEditor CreateMultiUrlPickerPropertyEditor() + { + var dataValueEditorFactory = Mock.Of(f => + f.Create(It.IsAny()) == + new DataValueEditor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + new DataEditorAttribute(Constants.PropertyEditors.Aliases.MultiUrlPicker))); + + return new MultiUrlPickerPropertyEditor(Mock.Of(), dataValueEditorFactory); + } + + #endregion +} From bdb9dd5bbca891367b1f530e6c8bdc9918768937 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 17 Feb 2026 15:16:40 +0100 Subject: [PATCH 15/24] ConvertToJsonNode optimization --- .../Services/Implement/PropertyEditorSchemaService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs index ac153f2ce1d4..4c4f586a1b32 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -132,6 +132,11 @@ public async Task, PropertyEditorSch return node; } + if (value is JsonElement element) + { + return element.Deserialize(); + } + if (value is string stringValue) { // Try to parse as JSON, otherwise treat as string literal From 225f0e778afb351bdbabd33953b7ed30b5aff717 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 17 Feb 2026 15:19:30 +0100 Subject: [PATCH 16/24] async refactor --- .../Services/ContentTypeJsonSchemaService.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs index 1cd50679d49a..19062ed8fab6 100644 --- a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs @@ -33,24 +33,24 @@ public ContentTypeJsonSchemaService( } /// - public Task GetDocumentTypeSchemaAsync(Guid key) + public async Task GetDocumentTypeSchemaAsync(Guid key) { - IContentType? contentType = _contentTypeService.Get(key); - return Task.FromResult(contentType is null ? null : BuildSchema(contentType, "document")); + IContentType? contentType = await _contentTypeService.GetAsync(key); + return contentType is null ? null : BuildSchema(contentType, "document"); } /// - public Task GetMediaTypeSchemaAsync(Guid key) + public async Task GetMediaTypeSchemaAsync(Guid key) { - IMediaType? mediaType = _mediaTypeService.Get(key); - return Task.FromResult(mediaType is null ? null : BuildSchema(mediaType, "media")); + IMediaType? mediaType = await _mediaTypeService.GetAsync(key); + return mediaType is null ? null : BuildSchema(mediaType, "media"); } /// - public Task GetMemberTypeSchemaAsync(Guid key) + public async Task GetMemberTypeSchemaAsync(Guid key) { - IMemberType? memberType = _memberTypeService.Get(key); - return Task.FromResult(memberType is null ? null : BuildSchema(memberType, "member")); + IMemberType? memberType = await _memberTypeService.GetAsync(key); + return memberType is null ? null : BuildSchema(memberType, "member"); } private JsonObject BuildSchema(IContentTypeComposition contentType, string contentKind) From 57eae20331b84b1d773e57150d2b077f2a279491 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 17 Feb 2026 16:13:32 +0100 Subject: [PATCH 17/24] Add editorUiAlias to x-umbraco-properties and make DataType ref route dynamic --- .../UmbracoBuilderExtensions.cs | 1 + .../Routing/IManagementApiRouteBuilder.cs | 20 +++++++ .../Routing/ManagementApiRouteBuilder.cs | 52 +++++++++++++++++++ .../Services/ContentTypeJsonSchemaService.cs | 34 ++++++++---- 4 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Routing/IManagementApiRouteBuilder.cs create mode 100644 src/Umbraco.Cms.Api.Management/Routing/ManagementApiRouteBuilder.cs diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index a99e6de55303..f1d977ef4fd5 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -21,6 +21,7 @@ public static IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build IServiceCollection services = builder.Services; builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddUnique(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Management/Routing/IManagementApiRouteBuilder.cs b/src/Umbraco.Cms.Api.Management/Routing/IManagementApiRouteBuilder.cs new file mode 100644 index 000000000000..4b1c26fa8c25 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Routing/IManagementApiRouteBuilder.cs @@ -0,0 +1,20 @@ +using System.Linq.Expressions; + +namespace Umbraco.Cms.Api.Management.Routing; + +/// +/// Provides URL generation for Management API controllers from non-controller contexts. +/// +public interface IManagementApiRouteBuilder +{ + /// + /// Generates a path to an action on a Management API controller. + /// + /// The controller type. + /// Expression selecting the action name (e.g., c => nameof(c.Schema)). + /// Route values (e.g., new { id = guid }). + /// The generated path, or null if the route could not be found. + string? GetPathByAction( + Expression> action, + object? routeValues = null); +} diff --git a/src/Umbraco.Cms.Api.Management/Routing/ManagementApiRouteBuilder.cs b/src/Umbraco.Cms.Api.Management/Routing/ManagementApiRouteBuilder.cs new file mode 100644 index 000000000000..5e25e63926f2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Routing/ManagementApiRouteBuilder.cs @@ -0,0 +1,52 @@ +using System.Linq.Expressions; +using Asp.Versioning; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.Controllers; + +namespace Umbraco.Cms.Api.Management.Routing; + +/// +/// Provides URL generation for Management API controllers using LinkGenerator. +/// +/// +/// This service mirrors the CreatedAtId pattern used in ManagementApiControllerBase +/// but works in non-controller contexts where IUrlHelper is not available. +/// +internal sealed class ManagementApiRouteBuilder : IManagementApiRouteBuilder +{ + private readonly LinkGenerator _linkGenerator; + private readonly ApiVersioningOptions _apiVersioningOptions; + + public ManagementApiRouteBuilder( + LinkGenerator linkGenerator, + IOptions apiVersioningOptions) + { + _linkGenerator = linkGenerator; + _apiVersioningOptions = apiVersioningOptions.Value; + } + + /// + public string? GetPathByAction( + Expression> action, + object? routeValues = null) + { + if (action.Body is not ConstantExpression constantExpression) + { + throw new ArgumentException("Expression must be a constant expression.", nameof(action)); + } + + var controllerName = ManagementApiRegexes.ControllerTypeToNameRegex() + .Replace(typeof(TController).Name, string.Empty); + var actionName = constantExpression.Value?.ToString() + ?? throw new ArgumentException("Expression does not have a value.", nameof(action)); + + // Merge provided route values with the required API version + var allRouteValues = new RouteValueDictionary(routeValues) + { + ["version"] = _apiVersioningOptions.DefaultApiVersion.MajorVersion?.ToString(), + }; + + return _linkGenerator.GetPathByAction(actionName, controllerName, allRouteValues); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs index 19062ed8fab6..d8b45dcf3af0 100644 --- a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs @@ -1,4 +1,6 @@ using System.Text.Json.Nodes; +using Umbraco.Cms.Api.Management.Controllers.DataType; +using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -10,12 +12,13 @@ namespace Umbraco.Cms.Api.Management.Services; internal sealed class ContentTypeJsonSchemaService : IContentTypeJsonSchemaService { private const string JsonSchemaVersion = "https://json-schema.org/draft/2020-12/schema"; - private const string DataTypeSchemaEndpointTemplate = "/umbraco/management/api/v1/data-type/{0}/schema"; private readonly IContentTypeService _contentTypeService; private readonly IMediaTypeService _mediaTypeService; private readonly IMemberTypeService _memberTypeService; private readonly IPropertyEditorSchemaService _propertyEditorSchemaService; + private readonly IManagementApiRouteBuilder _routeBuilder; + private readonly IDataTypeService _dataTypeService; /// /// Initializes a new instance of the class. @@ -24,36 +27,40 @@ public ContentTypeJsonSchemaService( IContentTypeService contentTypeService, IMediaTypeService mediaTypeService, IMemberTypeService memberTypeService, - IPropertyEditorSchemaService propertyEditorSchemaService) + IPropertyEditorSchemaService propertyEditorSchemaService, + IManagementApiRouteBuilder routeBuilder, + IDataTypeService dataTypeService) { _contentTypeService = contentTypeService; _mediaTypeService = mediaTypeService; _memberTypeService = memberTypeService; _propertyEditorSchemaService = propertyEditorSchemaService; + _routeBuilder = routeBuilder; + _dataTypeService = dataTypeService; } /// public async Task GetDocumentTypeSchemaAsync(Guid key) { IContentType? contentType = await _contentTypeService.GetAsync(key); - return contentType is null ? null : BuildSchema(contentType, "document"); + return contentType is null ? null : await BuildSchemaAsync(contentType, "document"); } /// public async Task GetMediaTypeSchemaAsync(Guid key) { IMediaType? mediaType = await _mediaTypeService.GetAsync(key); - return mediaType is null ? null : BuildSchema(mediaType, "media"); + return mediaType is null ? null : await BuildSchemaAsync(mediaType, "media"); } /// public async Task GetMemberTypeSchemaAsync(Guid key) { IMemberType? memberType = await _memberTypeService.GetAsync(key); - return memberType is null ? null : BuildSchema(memberType, "member"); + return memberType is null ? null : await BuildSchemaAsync(memberType, "member"); } - private JsonObject BuildSchema(IContentTypeComposition contentType, string contentKind) + private async Task BuildSchemaAsync(IContentTypeComposition contentType, string contentKind) { var schema = new JsonObject { @@ -75,7 +82,7 @@ private JsonObject BuildSchema(IContentTypeComposition contentType, string conte ["format"] = "uuid", ["description"] = "Optional key for the new content item", }, - ["values"] = BuildValuesSchema(contentType), + ["values"] = await BuildValuesSchemaAsync(contentType), ["variants"] = new JsonObject { ["$ref"] = "#/$defs/variants" }, }; @@ -123,7 +130,7 @@ private static JsonObject BuildContentTypeReference(Guid contentTypeKey) }, }; - private JsonObject BuildValuesSchema(IContentTypeComposition contentType) + private async Task BuildValuesSchemaAsync(IContentTypeComposition contentType) { IPropertyType[] propertyTypes = contentType.CompositionPropertyTypes.ToArray(); @@ -140,6 +147,10 @@ private JsonObject BuildValuesSchema(IContentTypeComposition contentType) itemsAllOf.Add(ifThen); } + // get all relevant datatypes + IDataType[] dataTypes = (await _dataTypeService.GetAllAsync(propertyTypes.Select(propertyType => propertyType.DataTypeKey).Distinct() + .ToArray())).ToArray(); + // Build x-umbraco-properties metadata var propertiesMetadata = new JsonObject(); foreach (IPropertyType propertyType in propertyTypes) @@ -148,6 +159,7 @@ private JsonObject BuildValuesSchema(IContentTypeComposition contentType) { ["dataTypeId"] = propertyType.DataTypeKey.ToString(), ["editorAlias"] = propertyType.PropertyEditorAlias, + ["editorUiAlias"] = dataTypes.FirstOrDefault(datatype => datatype.Key == propertyType.DataTypeKey)?.EditorUiAlias, ["mandatory"] = propertyType.Mandatory, ["variations"] = propertyType.Variations.ToString(), }; @@ -170,9 +182,13 @@ private JsonObject BuildPropertyIfThen(IPropertyType propertyType) if (_propertyEditorSchemaService.SupportsSchema(propertyType.PropertyEditorAlias)) { // Add $ref to the data type schema endpoint + var schemaPath = _routeBuilder.GetPathByAction( + c => nameof(c.Schema), + new { id = propertyType.DataTypeKey }); + thenProperties["value"] = new JsonObject { - ["$ref"] = string.Format(DataTypeSchemaEndpointTemplate, propertyType.DataTypeKey), + ["$ref"] = schemaPath, }; } From fd966495417fe13e0e2a7616a8a182b6b66baef5 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 18 Feb 2026 09:33:50 +0100 Subject: [PATCH 18/24] Removed JsonSchema.net due to possible license issues --- Directory.Packages.props | 3 +- .../ValidateSchemaDataTypeController.cs | 60 ---- .../Services/IPropertyEditorSchemaService.cs | 11 - .../Implement/PropertyEditorSchemaService.cs | 190 ----------- .../Umbraco.Infrastructure.csproj | 1 - .../PropertyEditorSchemaServiceTests.cs | 112 ------- .../PropertyEditorSchemaServiceTests.cs | 104 ------ ...ropertyEditorSchemaServiceTests_pickers.cs | 299 ------------------ 8 files changed, 1 insertion(+), 779 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a2644daef909..3afef8cece03 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -49,7 +49,6 @@ - @@ -89,4 +88,4 @@ - + \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs deleted file mode 100644 index dad765ffc246..000000000000 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/ValidateSchemaDataTypeController.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.DataType; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Services.OperationStatus; - -namespace Umbraco.Cms.Api.Management.Controllers.DataType; - -/// -/// Controller for validating values against data type schemas. -/// -[ApiVersion("1.0")] -public class ValidateSchemaDataTypeController : DataTypeControllerBase -{ - private readonly IPropertyEditorSchemaService _schemaService; - - /// - /// Initializes a new instance of the class. - /// - /// The property editor schema service. - public ValidateSchemaDataTypeController(IPropertyEditorSchemaService schemaService) - => _schemaService = schemaService; - - /// - /// Validates a value against the data type's JSON Schema. - /// - /// The unique identifier of the data type. - /// The request containing the value to validate. - /// A collection of validation errors (empty if validation passes). - /// - /// Returns validation results for property editors that implement IValueSchemaProvider. - /// Returns 404 if the data type is not found or doesn't support schema information. - /// - [HttpPost("{id:guid}/schema/validate")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - public async Task ValidateValue(Guid id, ValidateDataTypeValueRequestModel requestModel) - { - Attempt, PropertyEditorSchemaOperationStatus> attempt = - await _schemaService.ValidateValueAsync(id, requestModel.Value); - - if (attempt.Success is false) - { - return PropertyEditorSchemaOperationStatusResult(attempt.Status); - } - - IEnumerable results = attempt.Result - .Select(r => new SchemaValidationResultResponseModel - { - Message = r.Message, - Path = r.Path, - Keyword = r.Keyword, - }); - - return Ok(results); - } -} diff --git a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs index 13d1fab12f58..9f723044fabc 100644 --- a/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs +++ b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs @@ -54,15 +54,4 @@ public interface IPropertyEditorSchemaService : IService /// The alias of the property editor. /// true if the editor implements ; otherwise, false. bool SupportsSchema(string propertyEditorAlias); - - /// - /// Validates a value against the JSON Schema for a specific data type. - /// - /// The unique key of the data type. - /// The value to validate, as a JSON string or JSON-compatible object. - /// - /// An attempt containing a collection of validation results (empty if validation passes, or errors if not), - /// or an appropriate operation status if the data type was not found or doesn't support schemas. - /// - Task, PropertyEditorSchemaOperationStatus>> ValidateValueAsync(Guid dataTypeKey, object? value); } diff --git a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs index 4c4f586a1b32..d3e0635ec61f 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -1,6 +1,4 @@ -using System.Text.Json; using System.Text.Json.Nodes; -using Json.Schema; using Umbraco.Cms.Core; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; @@ -68,200 +66,12 @@ public async Task GetSchemaProvider(propertyEditorAlias) is not null; - /// - public async Task, PropertyEditorSchemaOperationStatus>> ValidateValueAsync(Guid dataTypeKey, object? value) - { - Attempt schemaAttempt = await GetSchemaAsync(dataTypeKey); - if (schemaAttempt.Success is false) - { - return Attempt.FailWithStatus(schemaAttempt.Status, Enumerable.Empty()); - } - - JsonObject? schemaJson = schemaAttempt.Result.JsonSchema; - if (schemaJson is null) - { - // Schema provider returned null schema - validation passes - return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, Enumerable.Empty()); - } - - try - { - // Parse the schema - JsonSchema schema = JsonSchema.FromText(schemaJson.ToJsonString()); - - // Convert value to JsonNode for evaluation - JsonNode? valueNode = ConvertToJsonNode(value); - - // Evaluate the value against the schema - EvaluationOptions options = new() - { - OutputFormat = OutputFormat.List, - }; - - EvaluationResults results = schema.Evaluate(valueNode, options); - - if (results.IsValid) - { - return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, Enumerable.Empty()); - } - - // Collect validation errors - return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, ExtractValidationErrors(results).AsEnumerable()); - } - catch (JsonException ex) - { - return Attempt.SucceedWithStatus(PropertyEditorSchemaOperationStatus.Success, new[] { new SchemaValidationResult($"Invalid JSON: {ex.Message}") }.AsEnumerable()); - } - } - private static Type? GetValueTypeFromProvider(IValueSchemaProvider provider, object? configuration) => provider.GetValueType(configuration); private static JsonObject? GetValueSchemaFromProvider(IValueSchemaProvider provider, object? configuration) => provider.GetValueSchema(configuration); - private static JsonNode? ConvertToJsonNode(object? value) - { - if (value is null) - { - return null; - } - - if (value is JsonNode node) - { - return node; - } - - if (value is JsonElement element) - { - return element.Deserialize(); - } - - if (value is string stringValue) - { - // Try to parse as JSON, otherwise treat as string literal - try - { - return JsonNode.Parse(stringValue); - } - catch (JsonException) - { - return JsonValue.Create(stringValue); - } - } - - // Serialize other objects to JSON and parse - var json = JsonSerializer.Serialize(value); - return JsonNode.Parse(json); - } - - private static List ExtractValidationErrors(EvaluationResults results) - { - var errors = new List(); - CollectValidationErrors(results, errors); - - // JSON Schema validators report the same logical error multiple times through different - // schema evaluation paths. Deduplicate while collecting paths in a single pass. - var seen = new HashSet<(string, string?, string?)>(); - var allPaths = new HashSet(); - errors.RemoveAll(e => - { - var isDuplicate = !seen.Add((e.Message, e.Path, e.Keyword)); - if (!isDuplicate && e.Path is not null) - { - allPaths.Add(e.Path); - } - - return isDuplicate; - }); - - // When all errors lack paths, there's nothing to filter - if (allPaths.Count == 0) - { - return errors; - } - - // Schema validators bubble up failures to parent paths with generic "does not conform" - // messages. These add noise when we already have the specific child error. Identify - // which paths are parents of other paths so we can filter out their generic errors. - var parentPathsWithChildren = new HashSet(); - foreach (var path in allPaths) - { - var lastSlash = path.LastIndexOf('/'); - while (lastSlash > 0) - { - var parentPath = path[..lastSlash]; - if (allPaths.Contains(parentPath)) - { - parentPathsWithChildren.Add(parentPath); - } - - lastSlash = parentPath.LastIndexOf('/'); - } - } - - // Determine if there are errors at deeper levels (non-root paths). Root path is "" in JSON Pointer. - var hasDeepErrors = allPaths.Any(p => p.Length > 0); - - // Only filter pathless errors (Path is null) when more specific path errors exist. - // Don't filter empty paths ("") as this represents the root location which is valid. - // Similarly, filter generic parent errors (keyword=null) only when specific child errors exist. - errors.RemoveAll(e => - (e.Path is null && hasDeepErrors) || - (e.Keyword is null && parentPathsWithChildren.Contains(e.Path ?? string.Empty))); - - return errors; - } - - private static void CollectValidationErrors(EvaluationResults results, List errors) - { - if (results.Details is null || results.Details.Count == 0) - { - // No details, create an error from the current result - if (!results.IsValid && results.Errors is not null) - { - foreach (var error in results.Errors) - { - errors.Add(new SchemaValidationResult( - error.Value, - results.InstanceLocation?.ToString(), - error.Key)); - } - } - else if (!results.IsValid) - { - errors.Add(new SchemaValidationResult( - "Value does not conform to schema", - results.InstanceLocation?.ToString())); - } - - return; - } - - // Process nested results - foreach (EvaluationResults detail in results.Details) - { - if (detail.IsValid) - { - continue; - } - - if (detail.Errors is not null && detail.Errors.Count > 0) - { - foreach (var error in detail.Errors) - { - errors.Add(new SchemaValidationResult( - error.Value, - detail.InstanceLocation?.ToString(), - error.Key)); - } - } - - // Always recurse to find more specific errors - CollectValidationErrors(detail, errors); - } - } - private IValueSchemaProvider? GetSchemaProvider(string propertyEditorAlias) { IDataEditor? editor = _dataEditors.FirstOrDefault(e => e.Alias == propertyEditorAlias); diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index de7fc1ec4ad2..171c873e3d3f 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -37,7 +37,6 @@ - diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs index d0e4f933d48a..ed2eb27a5a21 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -112,116 +112,4 @@ public async Task GetSchemaAsync_Returns_SchemaNotSupported_For_Editor_Without_S Assert.That(result.Success, Is.False); Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); } - - [Test] - public async Task ValidateValueAsync_Returns_Success_Empty_For_Valid_Integer_Value() - { - // Arrange - var dataType = new DataType( - new IntegerPropertyEditor(DataValueEditorFactory), - ConfigurationEditorJsonSerializer) - { - Name = "Test Integer Validation", - DatabaseType = ValueStorageType.Integer, - ConfigurationData = new Dictionary - { - { "min", 0 }, - { "max", 100 }, - }, - }; - var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); - Assert.That(createResult.Success, Is.True); - - // Act - var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "50"); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - Assert.That(result.Result, Is.Empty); - } - - [Test] - public async Task ValidateValueAsync_Returns_Success_With_Errors_For_Out_Of_Range_Integer() - { - // Arrange - var dataType = new DataType( - new IntegerPropertyEditor(DataValueEditorFactory), - ConfigurationEditorJsonSerializer) - { - Name = "Test Integer Range Validation", - DatabaseType = ValueStorageType.Integer, - ConfigurationData = new Dictionary - { - { "min", 0 }, - { "max", 100 }, - }, - }; - var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); - Assert.That(createResult.Success, Is.True); - - // Act - var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "150"); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Result, Is.Not.Empty); - Assert.That(result.Result.Any(r => r.Keyword == "maximum"), Is.True); - } - - [Test] - public async Task ValidateValueAsync_Returns_Success_With_Errors_For_Invalid_Type() - { - // Arrange - var dataType = new DataType( - new IntegerPropertyEditor(DataValueEditorFactory), - ConfigurationEditorJsonSerializer) - { - Name = "Test Integer Type Validation", - DatabaseType = ValueStorageType.Integer, - }; - var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); - Assert.That(createResult.Success, Is.True); - - // Act - var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "\"not an integer\""); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Result, Is.Not.Empty); - Assert.That(result.Result.Any(r => r.Keyword == "type"), Is.True); - } - - [Test] - public async Task ValidateValueAsync_Returns_Success_Empty_For_Null_Value_When_Nullable() - { - // Arrange - var dataType = new DataType( - new IntegerPropertyEditor(DataValueEditorFactory), - ConfigurationEditorJsonSerializer) - { - Name = "Test Integer Null Validation", - DatabaseType = ValueStorageType.Integer, - }; - var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); - Assert.That(createResult.Success, Is.True); - - // Act - var result = await PropertyEditorSchemaService.ValidateValueAsync(dataType.Key, "null"); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Result, Is.Empty); - } - - [Test] - public async Task ValidateValueAsync_Returns_DataTypeNotFound_For_NonExistent_DataType() - { - // Act - var result = await PropertyEditorSchemaService.ValidateValueAsync(Guid.NewGuid(), "any value"); - - // Assert - Assert.That(result.Success, Is.False); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); - } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs index ec8ffa50167d..ec04ca78a4e0 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -179,110 +179,6 @@ public async Task GetSchemaAsync_Returns_SchemaNotSupported_When_Editor_Does_Not Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); } - [Test] - public async Task ValidateValueAsync_Returns_DataTypeNotFound_When_DataType_Not_Found() - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - _dataTypeServiceMock.Setup(x => x.GetAsync(dataTypeKey)).ReturnsAsync((IDataType?)null); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, "any value"); - - // Assert - Assert.That(result.Success, Is.False); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); - } - - [Test] - public async Task ValidateValueAsync_Returns_SchemaNotSupported_When_Editor_Does_Not_Support_Schema() - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var regularEditor = new MockRegularEditor(); - SetupDataEditors(regularEditor); - SetupDataType(dataTypeKey, "test.regular"); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, "any value"); - - // Assert - Assert.That(result.Success, Is.False); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); - } - - [Test] - public async Task ValidateValueAsync_Returns_Success_Empty_For_Valid_Value() - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var schemaProviderEditor = new MockSchemaProviderEditor(); - SetupDataEditors(schemaProviderEditor); - SetupDataType(dataTypeKey, "test.schemaProvider"); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, "\"valid string\""); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - Assert.That(result.Result, Is.Empty); - } - - [Test] - public async Task ValidateValueAsync_Returns_Success_With_Errors_For_Invalid_Value() - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var schemaProviderEditor = new MockSchemaProviderEditor(); - SetupDataEditors(schemaProviderEditor); - SetupDataType(dataTypeKey, "test.schemaProvider"); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, "123"); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - Assert.That(result.Result, Is.Not.Empty); - Assert.That(result.Result.First().Keyword, Is.EqualTo("type")); - } - - [Test] - public async Task ValidateValueAsync_Handles_JsonNode_Value() - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var schemaProviderEditor = new MockSchemaProviderEditor(); - SetupDataEditors(schemaProviderEditor); - SetupDataType(dataTypeKey, "test.schemaProvider"); - var jsonValue = JsonValue.Create("valid string"); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Result, Is.Empty); - } - - [Test] - public async Task ValidateValueAsync_Returns_Success_For_Invalid_Json_String() - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var schemaProviderEditor = new MockSchemaProviderEditor(); - SetupDataEditors(schemaProviderEditor); - SetupDataType(dataTypeKey, "test.schemaProvider"); - - // Act - pass a string that's not valid JSON but will be parsed as JSON string literal - var result = await _sut.ValidateValueAsync(dataTypeKey, "{invalid json}"); - - // Assert - it should be treated as a string value which validates against string schema - Assert.That(result.Success, Is.True); - Assert.That(result.Result, Is.Empty); - } - private void SetupDataEditors(params IDataEditor[] editors) { _dataEditors.Clear(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs deleted file mode 100644 index 8a64849604c2..000000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests_pickers.cs +++ /dev/null @@ -1,299 +0,0 @@ -using Moq; -using NUnit.Framework; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.IO; -using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Core.Strings; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Services; - -// tests that correct schema's are being returned by validating values against the schema for guids that support guids -public partial class PropertyEditorSchemaServiceTests -{ - #region Simple UUID Pickers (ContentPicker, MemberPicker, UserPicker) - - [TestCase("\"550e8400-e29b-41d4-a716-446655440000\"", true, TestName = "ValidGuidWithDashes_Succeeds")] - [TestCase("\"550e8400e29b41d4a716446655440000\"", true, TestName = "ValidGuidNoDashes_Succeeds")] - [TestCase("\"55xyz000-e29b-41d4-a716-446655440000\"", false, TestName = "InvalidGuid_Fails")] - [TestCase("\"818\"", false, TestName = "NumberAsString_Fails")] - public async Task ValidateValueAsync_ContentPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var contentPickerEditor = CreateContentPickerPropertyEditor(); - SetupDataEditors(contentPickerEditor); - SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.ContentPicker); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - - if (shouldBeValid) - { - Assert.That(result.Result, Is.Empty, "Expected no validation errors for a valid GUID"); - } - else - { - Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for an invalid GUID"); - Assert.That(result.Result.First().Keyword, Is.EqualTo("pattern")); - } - } - - [TestCase("\"550e8400-e29b-41d4-a716-446655440000\"", true, TestName = "ValidGuidWithDashes_Succeeds")] - [TestCase("\"550e8400e29b41d4a716446655440000\"", true, TestName = "ValidGuidNoDashes_Succeeds")] - [TestCase("\"55xyz000-e29b-41d4-a716-446655440000\"", false, TestName = "InvalidGuid_Fails")] - [TestCase("\"818\"", false, TestName = "NumberAsString_Fails")] - public async Task ValidateValueAsync_MemberPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var memberPickerEditor = CreateMemberPickerPropertyEditor(); - SetupDataEditors(memberPickerEditor); - SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MemberPicker); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - - if (shouldBeValid) - { - Assert.That(result.Result, Is.Empty, "Expected no validation errors for a valid GUID"); - } - else - { - Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for an invalid GUID"); - Assert.That(result.Result.First().Keyword, Is.EqualTo("pattern")); - } - } - - [TestCase("\"550e8400-e29b-41d4-a716-446655440000\"", true, TestName = "ValidGuidWithDashes_Succeeds")] - [TestCase("\"550e8400e29b41d4a716446655440000\"", true, TestName = "ValidGuidNoDashes_Succeeds")] - [TestCase("\"55xyz000-e29b-41d4-a716-446655440000\"", false, TestName = "InvalidGuid_Fails")] - [TestCase("\"818\"", false, TestName = "NumberAsString_Fails")] - public async Task ValidateValueAsync_UserPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var userPickerEditor = CreateUserPickerPropertyEditor(); - SetupDataEditors(userPickerEditor); - SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.UserPicker); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - - if (shouldBeValid) - { - Assert.That(result.Result, Is.Empty, "Expected no validation errors for a valid GUID"); - } - else - { - Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for an invalid GUID"); - Assert.That(result.Result.First().Keyword, Is.EqualTo("pattern")); - } - } - - #endregion - - #region MultiNodeTreePicker - - [TestCase("null", true, TestName = "Null_Succeeds")] - [TestCase("[]", true, TestName = "EmptyArray_Succeeds")] - [TestCase("[{\"type\":\"content\",\"unique\":\"550e8400-e29b-41d4-a716-446655440000\"}]", true, TestName = "ValidGuidWithDashes_Succeeds")] - [TestCase("[{\"type\":\"content\",\"unique\":\"550e8400e29b41d4a716446655440000\"}]", true, TestName = "ValidGuidNoDashes_Succeeds")] - [TestCase("[{\"type\":\"content\",\"unique\":\"invalid-guid\"}]", false, TestName = "InvalidGuid_Fails")] - [TestCase("[{\"type\":\"content\",\"unique\":\"818\"}]", false, TestName = "NumberAsString_Fails")] - [TestCase("[{\"type\":\"media\",\"unique\":\"550e8400-e29b-41d4-a716-446655440000\"},{\"type\":\"content\",\"unique\":\"660e8400-e29b-41d4-a716-446655440000\"}]", true, TestName = "MultipleValidItems_Succeeds")] - public async Task ValidateValueAsync_MultiNodeTreePicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var multiNodeTreePickerEditor = CreateMultiNodeTreePickerPropertyEditor(); - SetupDataEditors(multiNodeTreePickerEditor); - SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MultiNodeTreePicker); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - - if (shouldBeValid) - { - Assert.That(result.Result, Is.Empty, "Expected no validation errors for valid value"); - } - else - { - Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for invalid UUID value"); - } - } - - #endregion - - #region MediaPicker3 - - [TestCase("null", true, TestName = "Null_Succeeds")] - [TestCase("[]", true, TestName = "EmptyArray_Succeeds")] - [TestCase("[{\"key\":\"550e8400-e29b-41d4-a716-446655440000\",\"mediaKey\":\"660e8400-e29b-41d4-a716-446655440000\"}]", true, TestName = "ValidGuidsWithDashes_Succeeds")] - [TestCase("[{\"key\":\"550e8400e29b41d4a716446655440000\",\"mediaKey\":\"660e8400e29b41d4a716446655440000\"}]", true, TestName = "ValidGuidsNoDashes_Succeeds")] - [TestCase("[{\"key\":\"invalid-guid\",\"mediaKey\":\"660e8400-e29b-41d4-a716-446655440000\"}]", false, TestName = "InvalidKeyGuid_Fails")] - [TestCase("[{\"key\":\"550e8400-e29b-41d4-a716-446655440000\",\"mediaKey\":\"invalid-guid\"}]", false, TestName = "InvalidMediaKeyGuid_Fails")] - [TestCase("[{\"key\":\"818\",\"mediaKey\":\"660e8400-e29b-41d4-a716-446655440000\"}]", false, TestName = "NumberAsStringKey_Fails")] - public async Task ValidateValueAsync_MediaPicker3_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var mediaPicker3Editor = CreateMediaPicker3PropertyEditor(); - SetupDataEditors(mediaPicker3Editor); - SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MediaPicker3); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - - if (shouldBeValid) - { - Assert.That(result.Result, Is.Empty, "Expected no validation errors for valid value"); - } - else - { - Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for invalid UUID value"); - } - } - - #endregion - - #region MultiUrlPicker - - [TestCase("null", true, TestName = "Null_Succeeds")] - [TestCase("[]", true, TestName = "EmptyArray_Succeeds")] - [TestCase("[{\"unique\":\"550e8400-e29b-41d4-a716-446655440000\",\"type\":\"document\"}]", true, TestName = "ValidGuidWithDashes_Succeeds")] - [TestCase("[{\"unique\":\"550e8400e29b41d4a716446655440000\",\"type\":\"document\"}]", true, TestName = "ValidGuidNoDashes_Succeeds")] - [TestCase("[{\"unique\":null,\"type\":\"external\",\"url\":\"https://example.com\"}]", true, TestName = "NullUniqueForExternal_Succeeds")] - [TestCase("[{\"url\":\"https://example.com\",\"type\":\"external\"}]", true, TestName = "NoUniqueForExternal_Succeeds")] - [TestCase("[{\"unique\":\"invalid-guid\",\"type\":\"document\"}]", false, TestName = "InvalidGuid_Fails")] - [TestCase("[{\"unique\":\"818\",\"type\":\"document\"}]", false, TestName = "NumberAsString_Fails")] - public async Task ValidateValueAsync_MultiUrlPicker_Validates_Uuid_Pattern(string jsonValue, bool shouldBeValid) - { - // Arrange - var dataTypeKey = Guid.NewGuid(); - var multiUrlPickerEditor = CreateMultiUrlPickerPropertyEditor(); - SetupDataEditors(multiUrlPickerEditor); - SetupDataType(dataTypeKey, Constants.PropertyEditors.Aliases.MultiUrlPicker); - - // Act - var result = await _sut.ValidateValueAsync(dataTypeKey, jsonValue); - - // Assert - Assert.That(result.Success, Is.True); - Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.Success)); - - if (shouldBeValid) - { - Assert.That(result.Result, Is.Empty, "Expected no validation errors for valid value"); - } - else - { - Assert.That(result.Result, Is.Not.Empty, "Expected validation errors for invalid UUID value"); - } - } - - #endregion - - #region Picker Editor Factory Methods - - private static ContentPickerPropertyEditor CreateContentPickerPropertyEditor() - { - var dataValueEditorFactory = Mock.Of(f => - f.Create(It.IsAny()) == - new DataValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute(Constants.PropertyEditors.Aliases.ContentPicker))); - - return new ContentPickerPropertyEditor(dataValueEditorFactory, Mock.Of()); - } - - private static MemberPickerPropertyEditor CreateMemberPickerPropertyEditor() - { - var dataValueEditorFactory = Mock.Of(f => - f.Create(It.IsAny()) == - new DataValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute(Constants.PropertyEditors.Aliases.MemberPicker))); - - return new MemberPickerPropertyEditor(dataValueEditorFactory); - } - - private static UserPickerPropertyEditor CreateUserPickerPropertyEditor() - { - var dataValueEditorFactory = Mock.Of(f => - f.Create(It.IsAny()) == - new DataValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute(Constants.PropertyEditors.Aliases.UserPicker))); - - return new UserPickerPropertyEditor(dataValueEditorFactory); - } - - private static MultiNodeTreePickerPropertyEditor CreateMultiNodeTreePickerPropertyEditor() - { - var dataValueEditorFactory = Mock.Of(f => - f.Create(It.IsAny()) == - new DataValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute(Constants.PropertyEditors.Aliases.MultiNodeTreePicker))); - - return new MultiNodeTreePickerPropertyEditor(dataValueEditorFactory, Mock.Of()); - } - - private static MediaPicker3PropertyEditor CreateMediaPicker3PropertyEditor() - { - var dataValueEditorFactory = Mock.Of(f => - f.Create(It.IsAny()) == - new DataValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute(Constants.PropertyEditors.Aliases.MediaPicker3))); - - return new MediaPicker3PropertyEditor(dataValueEditorFactory, Mock.Of()); - } - - private static MultiUrlPickerPropertyEditor CreateMultiUrlPickerPropertyEditor() - { - var dataValueEditorFactory = Mock.Of(f => - f.Create(It.IsAny()) == - new DataValueEditor( - Mock.Of(), - Mock.Of(), - Mock.Of(), - new DataEditorAttribute(Constants.PropertyEditors.Aliases.MultiUrlPicker))); - - return new MultiUrlPickerPropertyEditor(Mock.Of(), dataValueEditorFactory); - } - - #endregion -} From 2baa4fb92e5c7b7e6fe780d9490e35b658b3fa29 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 14:36:08 +0100 Subject: [PATCH 19/24] Use void editor in the noop schema test --- .../Services/PropertyEditorSchemaServiceTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs index ed2eb27a5a21..713b0727eea9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -94,12 +94,12 @@ public async Task GetSchemaAsync_Returns_DataTypeNotFound_For_NonExistent_DataTy [Test] public async Task GetSchemaAsync_Returns_SchemaNotSupported_For_Editor_Without_Schema() { - // Arrange - Label editor doesn't implement IValueSchemaProvider + // Arrange - Void editor doesn't implement IValueSchemaProvider var dataType = new DataType( - new LabelPropertyEditor(DataValueEditorFactory, IOHelper), + new VoidEditor(DataValueEditorFactory), ConfigurationEditorJsonSerializer) { - Name = "Test Label GetSchemaAsync", + Name = "Test Void GetSchemaAsync", DatabaseType = ValueStorageType.Nvarchar, }; var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); From 7a268b66c5e7fb1d2c2f8684fcf7af912e63ce7a Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 23:19:26 +0100 Subject: [PATCH 20/24] Cleanup leftovers from Schema validation removal --- .../SchemaValidationResultResponseModel.cs | 22 ------------------- .../ValidateDataTypeValueRequestModel.cs | 12 ---------- 2 files changed, 34 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs deleted file mode 100644 index f458992be710..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/SchemaValidationResultResponseModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.DataType; - -/// -/// Represents a single validation error from schema validation. -/// -public class SchemaValidationResultResponseModel -{ - /// - /// Gets or sets the validation error message. - /// - public required string Message { get; set; } - - /// - /// Gets or sets the JSON path where the error occurred (e.g., "$.items[0].name"). - /// - public string? Path { get; set; } - - /// - /// Gets or sets the JSON Schema keyword that failed (e.g., "type", "required", "minimum"). - /// - public string? Keyword { get; set; } -} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs deleted file mode 100644 index abbf8b1fed7e..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/ValidateDataTypeValueRequestModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Umbraco.Cms.Api.Management.ViewModels.DataType; - -/// -/// Request model for validating a value against a data type's schema. -/// -public class ValidateDataTypeValueRequestModel -{ - /// - /// The value to validate. Can be any JSON-compatible value (object, array, string, number, boolean, or null). - /// - public object? Value { get; set; } -} From b23b94b2088135dbc211fd8c3663deecd773a2f4 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 23:19:45 +0100 Subject: [PATCH 21/24] Move batch logic into batchcontroller --- ...ypeController.cs => BatchSchemasDataTypeController.cs} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename src/Umbraco.Cms.Api.Management/Controllers/DataType/{SchemasDataTypeController.cs => BatchSchemasDataTypeController.cs} (90%) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/BatchSchemasDataTypeController.cs similarity index 90% rename from src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs rename to src/Umbraco.Cms.Api.Management/Controllers/DataType/BatchSchemasDataTypeController.cs index 3b3093a11c7e..989137a678e1 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemasDataTypeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/BatchSchemasDataTypeController.cs @@ -13,15 +13,15 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType; /// Controller for retrieving multiple data type value schemas in a single request. /// [ApiVersion("1.0")] -public class SchemasDataTypeController : DataTypeControllerBase +public class BatchSchemasDataTypeController : DataTypeControllerBase { private readonly IPropertyEditorSchemaService _schemaService; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The property editor schema service. - public SchemasDataTypeController(IPropertyEditorSchemaService schemaService) + public BatchSchemasDataTypeController(IPropertyEditorSchemaService schemaService) => _schemaService = schemaService; /// @@ -34,7 +34,7 @@ public SchemasDataTypeController(IPropertyEditorSchemaService schemaService) /// Returns schema information for property editors that implement IValueSchemaProvider. /// Each item includes an error field if the schema could not be retrieved (e.g., data type not found or schema not supported). /// - [HttpGet("schema")] + [HttpGet("schemas/batch")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(FetchResponseModel), StatusCodes.Status200OK)] public async Task GetSchemas( From 903036b121c76412c77fe5a07084f4772ec79794 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 23:22:18 +0100 Subject: [PATCH 22/24] Update src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs Co-authored-by: Andy Butland --- .../PropertyEditors/BlockJsonSchemaHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs index c2c7c48df1d1..16a1b8460042 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs @@ -9,7 +9,7 @@ namespace Umbraco.Cms.Infrastructure.PropertyEditors; /// /// Helper class for building JSON schemas for block-based property editors. /// -public static class BlockJsonSchemaHelper +internal sealed static class BlockJsonSchemaHelper { /// /// Creates the base block item data schema used by all block editors. From 7ba20858506459d700872241635f395dcd9c4bbe Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 23:23:41 +0100 Subject: [PATCH 23/24] Update src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs Improve lookup on building propertymetadata Co-authored-by: Andy Butland --- .../Services/ContentTypeJsonSchemaService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs index d8b45dcf3af0..fa6798ed8fbd 100644 --- a/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs @@ -151,6 +151,9 @@ private async Task BuildValuesSchemaAsync(IContentTypeComposition co IDataType[] dataTypes = (await _dataTypeService.GetAllAsync(propertyTypes.Select(propertyType => propertyType.DataTypeKey).Distinct() .ToArray())).ToArray(); + // Index by key for O(1) lookup + Dictionary dataTypesByKey = dataTypes.ToDictionary(dt => dt.Key); + // Build x-umbraco-properties metadata var propertiesMetadata = new JsonObject(); foreach (IPropertyType propertyType in propertyTypes) @@ -159,7 +162,7 @@ private async Task BuildValuesSchemaAsync(IContentTypeComposition co { ["dataTypeId"] = propertyType.DataTypeKey.ToString(), ["editorAlias"] = propertyType.PropertyEditorAlias, - ["editorUiAlias"] = dataTypes.FirstOrDefault(datatype => datatype.Key == propertyType.DataTypeKey)?.EditorUiAlias, + ["editorUiAlias"] = dataTypesByKey.GetValueOrDefault(propertyType.DataTypeKey)?.EditorUiAlias, ["mandatory"] = propertyType.Mandatory, ["variations"] = propertyType.Variations.ToString(), }; From a5a4cfa4f440360b035cdcdb956b93ca91a30b46 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 12 Mar 2026 06:36:35 +0100 Subject: [PATCH 24/24] Fixed build error. --- .../PropertyEditors/BlockJsonSchemaHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs index 16a1b8460042..f2c38c328d19 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs @@ -9,7 +9,7 @@ namespace Umbraco.Cms.Infrastructure.PropertyEditors; /// /// Helper class for building JSON schemas for block-based property editors. /// -internal sealed static class BlockJsonSchemaHelper +internal static class BlockJsonSchemaHelper { /// /// Creates the base block item data schema used by all block editors.