diff --git a/Directory.Packages.props b/Directory.Packages.props index a3bfb51cce81..b43fec60321c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -89,4 +89,4 @@ - + \ No newline at end of file diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/BatchSchemasDataTypeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/BatchSchemasDataTypeController.cs new file mode 100644 index 000000000000..989137a678e1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/BatchSchemasDataTypeController.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 BatchSchemasDataTypeController : DataTypeControllerBase +{ + private readonly IPropertyEditorSchemaService _schemaService; + + /// + /// Initializes a new instance of the class. + /// + /// The property editor schema service. + public BatchSchemasDataTypeController(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("schemas/batch")] + [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/Controllers/DataType/DataTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs index 84f70e91c49a..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,6 +55,21 @@ protected IActionResult DataTypeOperationStatusResult(DataTypeOperationStatus st protected IActionResult DataTypeNotFound() => OperationStatusResult(DataTypeOperationStatus.NotFound, DataTypeNotFound); + 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") 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..45f43e08f614 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/SchemaDataTypeController.cs @@ -0,0 +1,54 @@ +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 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) + { + Attempt attempt = await _schemaService.GetSchemaAsync(id); + if (attempt.Success is false) + { + return PropertyEditorSchemaOperationStatusResult(attempt.Status); + } + + PropertyValueSchema result = attempt.Result; + return Ok(new DataTypeSchemaResponseModel + { + ValueTypeName = result.ValueType?.FullName, + JsonSchema = result.JsonSchema, + }); + } +} 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/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/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/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 new file mode 100644 index 000000000000..fa6798ed8fbd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/ContentTypeJsonSchemaService.cs @@ -0,0 +1,294 @@ +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; + +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 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. + /// + public ContentTypeJsonSchemaService( + IContentTypeService contentTypeService, + IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + 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 : await BuildSchemaAsync(contentType, "document"); + } + + /// + public async Task GetMediaTypeSchemaAsync(Guid key) + { + IMediaType? mediaType = await _mediaTypeService.GetAsync(key); + 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 : await BuildSchemaAsync(memberType, "member"); + } + + private async Task BuildSchemaAsync(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"] = await BuildValuesSchemaAsync(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 async Task BuildValuesSchemaAsync(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); + } + + // get all relevant datatypes + 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) + { + propertiesMetadata[propertyType.Alias] = new JsonObject + { + ["dataTypeId"] = propertyType.DataTypeKey.ToString(), + ["editorAlias"] = propertyType.PropertyEditorAlias, + ["editorUiAlias"] = dataTypesByKey.GetValueOrDefault(propertyType.DataTypeKey)?.EditorUiAlias, + ["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 + var schemaPath = _routeBuilder.GetPathByAction( + c => nameof(c.Schema), + new { id = propertyType.DataTypeKey }); + + thenProperties["value"] = new JsonObject + { + ["$ref"] = schemaPath, + }; + } + + // 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/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/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.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; } = []; +} diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs index 747ab67d82d5..0c98f5643e62 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,19 @@ 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"), + ["format"] = "uuid", + ["pattern"] = ValueSchemaPatterns.Uuid, + ["description"] = "GUID of the selected document", + }; + /// protected override IConfigurationEditor CreateConfigurationEditor() => new ContentPickerConfigurationEditor(_ioHelper); 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 new file mode 100644 index 000000000000..7f4569e2c591 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IValueSchemaProvider.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Provides schema information about the values a property editor accepts. +/// +/// +/// +/// 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 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 incoming value structure. + /// + /// The data type configuration, which may affect the value type. + /// + /// 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 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 incoming 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 receives + /// (i.e., what the Management API accepts). + /// + /// + /// 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/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.Core/Services/IPropertyEditorSchemaService.cs b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs new file mode 100644 index 000000000000..9f723044fabc --- /dev/null +++ b/src/Umbraco.Core/Services/IPropertyEditorSchemaService.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services.OperationStatus; + +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 complete schema information for a specific data type, including both CLR type and JSON Schema. + /// + /// The unique key of the data type. + /// + /// 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> GetSchemaAsync(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.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.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/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..5da4d046d6c9 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; @@ -13,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; @@ -21,7 +23,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 +36,87 @@ 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 (BlockGrid-specific) + var areaItemSchema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["key"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["items"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["$ref"] = "#/$defs/layoutItem" }, + }, + }, + }; + + // 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"] = "array", + ["items"] = areaItemSchema, + }; + + // 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 + { + ["layoutItem"] = layoutItemSchema, + }; + schema["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"] = contentDataItemSchema, + }, + ["settingsData"] = new JsonObject + { + ["type"] = "array", + ["items"] = settingsDataItemSchema, + }, + }; + + // 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 + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockGrid]!.AsObject(); + BlockJsonSchemaHelper.ApplyValidationConstraints(layoutArray, config?.ValidationLimit?.Min, config?.ValidationLimit?.Max); + + return schema; + } #region Value Editor diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockJsonSchemaHelper.cs new file mode 100644 index 000000000000..f2c38c328d19 --- /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. +/// +internal 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 322d2927fb7a..51bc6ba6f2a1 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; @@ -10,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; @@ -17,7 +19,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 +38,64 @@ 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 + 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 the main schema + JsonObject schema = BlockJsonSchemaHelper.CreateRootSchema(); + schema["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"] = contentDataItemSchema, + }, + ["settingsData"] = new JsonObject + { + ["type"] = "array", + ["items"] = settingsDataItemSchema, + }, + ["expose"] = new JsonObject + { + ["type"] = "array", + ["items"] = exposeItemSchema, + }, + }; + + // Add validation constraints + var layoutArray = schema["properties"]!["layout"]!["properties"]![Constants.PropertyEditors.Aliases.BlockList]!.AsObject(); + BlockJsonSchemaHelper.ApplyValidationConstraints(layoutArray, config?.ValidationLimit.Min, config?.ValidationLimit.Max); + + return schema; + } + /// /// Instantiates a new for use with the block list editor property value editor. /// 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 b065d139490c..892ffb43da8e 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 50fe850e04f2..ceaaefd2d215 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", ["pattern"] = ValueSchemaPatterns.Uuid }, + ["mediaKey"] = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["pattern"] = ValueSchemaPatterns.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/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 d76ff2b0ab19..24d4fe0c7b9e 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..6d2065a7ba62 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; @@ -17,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; @@ -28,7 +30,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 +57,75 @@ public RichTextPropertyEditor( public override bool SupportsConfigurableElements => true; + /// + public Type? GetValueType(object? configuration) => typeof(RichTextEditorValue); + + /// + public JsonObject? GetValueSchema(object? configuration) + { + 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 + { + ["type"] = new JsonArray("object", "null"), + ["properties"] = new JsonObject + { + ["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, + }, + }, + }; + + // Build main schema + return new JsonObject + { + ["$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"] = blocksSchema, + }, + ["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/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/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 705b51befde2..bd6c128cc413 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,17 @@ public TrueFalsePropertyEditor(IDataValueEditorFactory dataValueEditorFactory) : base(dataValueEditorFactory) => SupportsReadOnly = true; + /// + public Type? GetValueType(object? configuration) => typeof(bool); + + /// + public JsonObject? GetValueSchema(object? configuration) => new() + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["type"] = "boolean", + ["description"] = "Boolean value (true or false)", + }; + /// 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..d3e0635ec61f --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/PropertyEditorSchemaService.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Nodes; +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; + +/// +/// 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> GetSchemaAsync(Guid dataTypeKey) + { + var dataType = await _dataTypeService.GetAsync(dataTypeKey); + if (dataType is null) + { + return Attempt.FailWithStatus(PropertyEditorSchemaOperationStatus.DataTypeNotFound, new PropertyValueSchema(null, null)); + } + + IValueSchemaProvider? provider = GetSchemaProvider(dataType.EditorAlias); + if (provider is null) + { + return Attempt.FailWithStatus(PropertyEditorSchemaOperationStatus.SchemaNotSupported, new PropertyValueSchema(null, null)); + } + + 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 is not null ? GetValueTypeFromProvider(provider, configuration) : null; + } + + /// + public JsonObject? GetValueSchema(string propertyEditorAlias, object? configuration) + { + IValueSchemaProvider? provider = GetSchemaProvider(propertyEditorAlias); + return provider is not null ? GetValueSchemaFromProvider(provider, configuration) : null; + } + + /// + public bool SupportsSchema(string propertyEditorAlias) + => GetSchemaProvider(propertyEditorAlias) is not null; + + private static Type? GetValueTypeFromProvider(IValueSchemaProvider provider, object? configuration) + => provider.GetValueType(configuration); + + private static JsonObject? GetValueSchemaFromProvider(IValueSchemaProvider provider, object? configuration) + => provider.GetValueSchema(configuration); + + private IValueSchemaProvider? GetSchemaProvider(string propertyEditorAlias) + { + IDataEditor? editor = _dataEditors.FirstOrDefault(e => e.Alias == propertyEditorAlias); + return editor as IValueSchemaProvider; + } +} 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..713b0727eea9 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -0,0 +1,115 @@ +// 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.Core.Services.OperationStatus; +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 GetSchemaAsync_Returns_Success_For_Integer_DataType() + { + // Arrange + var dataType = new DataType( + new IntegerPropertyEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Integer GetSchemaAsync", + 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.GetSchemaAsync(dataType.Key); + + // Assert + 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 GetSchemaAsync_Returns_DataTypeNotFound_For_NonExistent_DataType() + { + // Act + var result = await PropertyEditorSchemaService.GetSchemaAsync(Guid.NewGuid()); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); + } + + [Test] + public async Task GetSchemaAsync_Returns_SchemaNotSupported_For_Editor_Without_Schema() + { + // Arrange - Void editor doesn't implement IValueSchemaProvider + var dataType = new DataType( + new VoidEditor(DataValueEditorFactory), + ConfigurationEditorJsonSerializer) + { + Name = "Test Void GetSchemaAsync", + DatabaseType = ValueStorageType.Nvarchar, + }; + var createResult = await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey); + Assert.That(createResult.Success, Is.True); + + // Act + var result = await PropertyEditorSchemaService.GetSchemaAsync(dataType.Key); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); + } +} 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..33f0f9f60fce --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueSchemaProviderTests.cs @@ -0,0 +1,144 @@ +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_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..ec04ca78a4e0 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyEditorSchemaServiceTests.cs @@ -0,0 +1,235 @@ +using System.Text.Json.Nodes; +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 partial class PropertyEditorSchemaServiceTests +{ + private Mock _dataTypeServiceMock = null!; + private List _dataEditors = null!; + private PropertyEditorSchemaService _sut = null!; + + [SetUp] + public void SetUp() + { + _dataTypeServiceMock = new Mock(); + _dataEditors = []; + 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 GetSchemaAsync_Returns_Success_With_Both_Type_And_Schema() + { + // Arrange + var dataTypeKey = Guid.NewGuid(); + var schemaProviderEditor = new MockSchemaProviderEditor(); + SetupDataEditors(schemaProviderEditor); + SetupDataType(dataTypeKey, "test.schemaProvider"); + + // Act + var result = await _sut.GetSchemaAsync(dataTypeKey); + + // Assert + 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 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.GetSchemaAsync(dataTypeKey); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.DataTypeNotFound)); + } + + [Test] + public async Task GetSchemaAsync_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.GetSchemaAsync(dataTypeKey); + + // Assert + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(PropertyEditorSchemaOperationStatus.SchemaNotSupported)); + } + + 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!; + } +}