diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c7d7df04..dc38ee1b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ The admin's `/audit` page offers the export and an item's history. A page cannot Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass. -Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject; a type outside Core also overrides `AddFieldTypes` to register its assembly. +Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. Every type also describes its values for the schema document agents read (`DescribeValue`, SCH-06): a JSON Schema for one value, an example that satisfies it and one sentence about it. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject; a type outside Core also overrides `AddFieldTypes` to register its assembly. ## Code style diff --git a/Directory.Packages.props b/Directory.Packages.props index 0522f2e33..dddc49271 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs index 3b86543e8..cbf6a5c22 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/BooleanFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -12,4 +13,9 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return new FieldSettingsReader(Name, settings).Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription(new JsonObject { ["type"] = "boolean" }, true, "true or false."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs index 1451e142c..0f6ff9f37 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/DateTimeFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,17 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldSettingsReader(Name, settings).Flag("dateOnly") + ? new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "date" }, + "2026-01-31", + "A calendar date as YYYY-MM-DD.") + : new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "date-time" }, + "2026-01-31T09:30:00Z", + "A point in time in ISO 8601 with an offset, such as 2026-01-31T09:30:00Z."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs index 9c20afd85..43aa75009 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/EnumFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,14 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var values = new FieldSettingsReader(Name, settings).RequiredStringList("values") ?? []; + + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["enum"] = new JsonArray([.. values.Select(value => (JsonNode?)value)]) }, + values.FirstOrDefault(), + $"One of: {string.Join(", ", values)}."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs b/src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs new file mode 100644 index 000000000..d338fd394 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/FieldTypes/Examples.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Core.Builtin.FieldTypes; + +internal static class Examples +{ + public static string Fit(string text, int? maxLength) + { + return maxLength is { } limit && text.Length > limit ? text[..limit] : text; + } + + public static string AtMost(int? maxLength) + { + return maxLength is { } limit ? $" of at most {limit} characters" : string.Empty; + } +} diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs index fc9418aa7..0a7a3b997 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/JsonFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -13,4 +14,9 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return new FieldSettingsReader(Name, settings).Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription(new JsonObject(), new JsonObject { ["key"] = "value" }, "Any JSON value."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs index 602f010fc..8e93d375f 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -30,6 +31,19 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object + ? value + : FieldDefinition.EmptySettings; + var item = fieldTypes.DescribeValue(settings.GetProperty("itemType").GetString()!, itemSettings); + + return new FieldValueDescription( + new JsonObject { ["type"] = "array", ["items"] = item.Schema }, + new JsonArray(item.Example), + $"A list of values. Each one: {item.Description}"); + } + public IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs index 3eb866f70..2d89c3e69 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/MediaFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -13,4 +14,12 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return new FieldSettingsReader(Name, settings).Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + FieldValueDescription.ExampleId, + "The id of an asset in the media library."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs index b4325d441..cdddac70e 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/NumberFieldType.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -32,4 +34,45 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var reader = new FieldSettingsReader(Name, settings); + var integer = reader.Flag("integer"); + var min = reader.Number("min"); + var max = reader.Number("max"); + + var schema = new JsonObject { ["type"] = integer ? "integer" : "number" }; + if (min is { } low) + { + schema["minimum"] = low; + } + + if (max is { } high) + { + schema["maximum"] = high; + } + + var range = (min, max) switch + { + (not null, not null) => string.Create(CultureInfo.InvariantCulture, $" from {min} to {max}"), + (not null, null) => string.Create(CultureInfo.InvariantCulture, $" of at least {min}"), + (null, not null) => string.Create(CultureInfo.InvariantCulture, $" of at most {max}"), + _ => string.Empty, + }; + + return new FieldValueDescription(schema, Example(integer, min ?? max ?? 0m), $"{(integer ? "A whole number" : "A number")}{range}."); + } + + // A whole number is written without the ".0" a setting may carry, so the example reads as an integer. + private static JsonNode Example(bool integer, decimal value) + { + if (!integer) + { + return value; + } + + var whole = decimal.Truncate(value); + return whole is >= long.MinValue and <= long.MaxValue ? decimal.ToInt64(whole) : whole; + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs index eea959cb6..64d79e0dd 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -29,6 +30,19 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var target = ReferenceTargets(settings, fieldTypes)[0]; + + // A field reference stores the entity, not the value, so the page always shows the entity's current fact (SCH-04, CNT-04). + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + FieldValueDescription.ExampleId, + target.Field is null + ? $"The id of an entry of the content type '{target.Type}'." + : $"The id of a '{target.Type}' entity; the field shows that entity's '{target.Field}' instead of a copy of it."); + } + public IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) { var field = settings.TryGetProperty("field", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs index 6805105ea..fed9c5769 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/RichTextFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,19 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var maxLength = new FieldSettingsReader(Name, settings).PositiveInteger("maxLength"); + var schema = new JsonObject { ["type"] = "string", ["contentMediaType"] = "text/markdown" }; + if (maxLength is { } limit) + { + schema["maxLength"] = limit; + } + + return new FieldValueDescription( + schema, + Examples.Fit("Some **Markdown** text.", maxLength), + $"Formatted text written as Markdown{Examples.AtMost(maxLength)}."); + } } diff --git a/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs b/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs index f52604951..ff3637bcc 100644 --- a/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs +++ b/src/FluentCMS.Core/Builtin/FieldTypes/TextFieldType.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; @@ -16,4 +17,16 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var maxLength = new FieldSettingsReader(Name, settings).PositiveInteger("maxLength"); + var schema = new JsonObject { ["type"] = "string" }; + if (maxLength is { } limit) + { + schema["maxLength"] = limit; + } + + return new FieldValueDescription(schema, Examples.Fit("Example text", maxLength), $"Plain text{Examples.AtMost(maxLength)}."); + } } diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 69dc3a629..5adc43b93 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -19,6 +19,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Reads through IAuditReader, which Infrastructure provides. services.AddScoped(); + // Reads through IContentTypeDirectory, which Infrastructure provides. + services.AddScoped(); + return services; } } diff --git a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs index ce9815d68..ff39c160a 100644 --- a/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs +++ b/src/FluentCMS.Core/Schema/ContentTypeDefinition.cs @@ -6,17 +6,20 @@ namespace FluentCMS.Core.Schema; // The fields of one content type version, stored as one JSON document (SCH-01, ADR-0005): -// {"entity":true,"fields":[{"name":"price","type":"number","required":true,"settings":{"min":0}}]} +// {"entity":true,"description":"...","fields":[{"name":"price","type":"number","required":true,"description":"...","settings":{"min":0}}]} public sealed class ContentTypeDefinition { + public const int MaxDescriptionLength = 1000; + private static readonly JsonDocumentOptions ParseOptions = new() { AllowDuplicateProperties = false }; - public ContentTypeDefinition(IEnumerable fields, bool isEntity = false) + public ContentTypeDefinition(IEnumerable fields, bool isEntity = false, string? description = null) { ArgumentNullException.ThrowIfNull(fields); Fields = fields.ToList(); IsEntity = isEntity; + Description = description; if (Fields.Any(field => field is null)) { throw new ArgumentException("A definition cannot contain a null field.", nameof(fields)); @@ -28,6 +31,9 @@ public ContentTypeDefinition(IEnumerable fields, bool isEntity // An entity type is a fact source: every one of its fields carries FactMetadata next to its value (SCH-03). public bool IsEntity { get; } + // What the type is for, in the author's words; the schema document shows it to agents (SCH-06). + public string? Description { get; } + // Checks the shape of the document only; what the names, types and settings mean is checked by Validate. public static Result Parse(string json) { @@ -46,10 +52,10 @@ public static Result Parse(string json) using (document) { var errors = new List(); - var (fields, isEntity) = ReadDocument(document.RootElement, errors); + var (fields, isEntity, description) = ReadDocument(document.RootElement, errors); return errors.Count == 0 - ? Result.Success(new ContentTypeDefinition(fields, isEntity)) + ? Result.Success(new ContentTypeDefinition(fields, isEntity, description)) : Result.Failure(errors); } } @@ -61,6 +67,7 @@ public string ToJson() { writer.WriteStartObject(); writer.WriteBoolean("entity", IsEntity); + WriteDescription(writer, Description); writer.WriteStartArray("fields"); foreach (var field in Fields) @@ -69,6 +76,7 @@ public string ToJson() writer.WriteString("name", field.Name); writer.WriteString("type", field.Type); writer.WriteBoolean("required", field.Required); + WriteDescription(writer, field.Description); writer.WritePropertyName("settings"); field.Settings.WriteTo(writer); writer.WriteEndObject(); @@ -89,6 +97,11 @@ public Result Validate(FieldTypeRegistry fieldTypes) var errors = new List(); var names = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!IsValidDescription(Description)) + { + errors.Add(DescriptionInvalid("The type's description")); + } + for (var index = 0; index < Fields.Count; index++) { var field = Fields[index]; @@ -109,6 +122,11 @@ public Result Validate(FieldTypeRegistry fieldTypes) $"{label}: another field already has this name; field names are compared ignoring case.")); } + if (!IsValidDescription(field.Description)) + { + errors.Add(DescriptionInvalid($"{label}: the description")); + } + errors.AddRange(fieldTypes.ValidateSettings(field.Type, field.Settings) .Select(error => error with { Message = $"{label}: {error.Message}" })); } @@ -133,15 +151,16 @@ public IReadOnlyList References(FieldTypeRegistry fieldTypes) } // A document without 'entity' is a plain content type, as every definition stored before the flag existed. - private static (List Fields, bool IsEntity) ReadDocument(JsonElement root, List errors) + private static (List Fields, bool IsEntity, string? Description) ReadDocument(JsonElement root, List errors) { var fields = new List(); var isEntity = false; + string? description = null; if (root.ValueKind != JsonValueKind.Object) { errors.Add(Malformed("$ must be an object with a 'fields' array.")); - return (fields, isEntity); + return (fields, isEntity, description); } foreach (var property in root.EnumerateObject()) @@ -156,8 +175,11 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl case "entity": errors.Add(Malformed("$.entity must be true or false.")); break; + case "description": + description = ReadDescription(property.Value, "$.description", errors); + break; default: - errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has 'entity' and 'fields'.")); + errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has 'entity', 'description' and 'fields'.")); break; } } @@ -165,7 +187,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl if (!root.TryGetProperty("fields", out var array) || array.ValueKind != JsonValueKind.Array) { errors.Add(Malformed("$.fields must be an array.")); - return (fields, isEntity); + return (fields, isEntity, description); } var index = 0; @@ -177,7 +199,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl } } - return (fields, isEntity); + return (fields, isEntity, description); } private static FieldDefinition? ReadField(JsonElement element, string path, List errors) @@ -192,6 +214,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl string? name = null; string? type = null; var required = false; + string? description = null; JsonElement? settings = null; foreach (var property in element.EnumerateObject()) @@ -211,6 +234,9 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl case "settings" when value.ValueKind == JsonValueKind.Object: settings = value; break; + case "description": + description = ReadDescription(value, $"{path}.description", errors); + break; case "name" or "type": errors.Add(Malformed($"{path}.{property.Name} must be a string.")); break; @@ -221,7 +247,7 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl errors.Add(Malformed($"{path}.settings must be an object.")); break; default: - errors.Add(Malformed($"{path}.{property.Name} is not part of a field; a field has name, type, required and settings.")); + errors.Add(Malformed($"{path}.{property.Name} is not part of a field; a field has name, type, required, description and settings.")); break; } } @@ -236,7 +262,42 @@ private static (List Fields, bool IsEntity) ReadDocument(JsonEl errors.Add(Malformed($"{path}.type is missing.")); } - return errors.Count == count ? new FieldDefinition(name!, type!, required, settings) : null; + return errors.Count == count ? new FieldDefinition(name!, type!, required, settings, description) : null; + } + + // An explicit null reads as no description. + private static string? ReadDescription(JsonElement value, string path, List errors) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + return value.GetString(); + case JsonValueKind.Null: + return null; + default: + errors.Add(Malformed($"{path} must be a string.")); + return null; + } + } + + private static void WriteDescription(Utf8JsonWriter writer, string? description) + { + if (description is not null) + { + writer.WriteString("description", description); + } + } + + private static bool IsValidDescription(string? description) + { + return description is null || (!string.IsNullOrWhiteSpace(description) && description.Length <= MaxDescriptionLength); + } + + private static Error DescriptionInvalid(string subject) + { + return new Error( + SchemaErrorCodes.DescriptionInvalid, + $"{subject} must not be blank and must be at most {MaxDescriptionLength} characters; leave it out instead."); } private static Error Malformed(string message) diff --git a/src/FluentCMS.Core/Schema/FactMetadata.cs b/src/FluentCMS.Core/Schema/FactMetadata.cs index 756c7659e..262caa303 100644 --- a/src/FluentCMS.Core/Schema/FactMetadata.cs +++ b/src/FluentCMS.Core/Schema/FactMetadata.cs @@ -11,6 +11,10 @@ public static class FactMetadata public const string ValidUntil = "validUntil"; public const string LastVerified = "lastVerified"; + // A source is {"kind": "url", "reference": "https://..."}; the reference is the URL, file, person or system it names. + public const string SourceKind = "kind"; + public const string SourceReference = "reference"; + public static IReadOnlyList Properties { get; } = [Source, Owner, ValidFrom, ValidUntil, LastVerified]; public static IReadOnlyList SourceKinds { get; } = ["url", "file", "person", "system"]; diff --git a/src/FluentCMS.Core/Schema/FieldDefinition.cs b/src/FluentCMS.Core/Schema/FieldDefinition.cs index 594a6f966..eb64ed6fc 100644 --- a/src/FluentCMS.Core/Schema/FieldDefinition.cs +++ b/src/FluentCMS.Core/Schema/FieldDefinition.cs @@ -5,7 +5,7 @@ namespace FluentCMS.Core.Schema; // One field of a content type definition. Not a record: settings are JSON, which has no value equality. public sealed class FieldDefinition { - public FieldDefinition(string name, string type, bool required = false, JsonElement? settings = null) + public FieldDefinition(string name, string type, bool required = false, JsonElement? settings = null, string? description = null) { ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(type); @@ -19,6 +19,7 @@ public FieldDefinition(string name, string type, bool required = false, JsonElem Name = name; Type = type; Required = required; + Description = description; // A clone outlives the document it was read from. Settings = resolved.Clone(); } @@ -31,6 +32,9 @@ public FieldDefinition(string name, string type, bool required = false, JsonElem public bool Required { get; } + // What the field means, in the author's words; the schema document shows it to agents (SCH-06). + public string? Description { get; } + // Read by the field type named in Type; always a JSON object. public JsonElement Settings { get; } diff --git a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs index 4c692fb99..e59ae0616 100644 --- a/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs +++ b/src/FluentCMS.Core/Schema/FieldTypeRegistry.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core.Results; namespace FluentCMS.Core.Schema; @@ -56,6 +57,15 @@ public IReadOnlyList ValidateSettings(string type, JsonElement settings) return fieldType.ValidateSettings(settings, this); } + // A type that is no longer installed is described as any value, so a stored definition that uses it still has a document. + public FieldValueDescription DescribeValue(string type, JsonElement settings) + { + ArgumentNullException.ThrowIfNull(type); + + return Find(type)?.DescribeValue(settings, this) + ?? new FieldValueDescription(new JsonObject(), null, $"A value of the field type '{type}', which is not installed."); + } + public IReadOnlyList ReferenceTargets(string type, JsonElement settings) { ArgumentNullException.ThrowIfNull(type); diff --git a/src/FluentCMS.Core/Schema/FieldValueDescription.cs b/src/FluentCMS.Core/Schema/FieldValueDescription.cs new file mode 100644 index 000000000..525d3f5c6 --- /dev/null +++ b/src/FluentCMS.Core/Schema/FieldValueDescription.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Schema; + +// How a value of one field looks, for the schema document agents read (SCH-06): a JSON Schema for one value, an example that +// satisfies it and a sentence about it. The example comes from the field type, so it is always a valid value. +public sealed record FieldValueDescription(JsonObject Schema, JsonNode? Example, string Description) +{ + // Stands in for the id of an entry, entity or asset in examples. + public const string ExampleId = "0199a000-0000-7000-8000-000000000001"; +} diff --git a/src/FluentCMS.Core/Schema/IFieldType.cs b/src/FluentCMS.Core/Schema/IFieldType.cs index 82f71a5b0..b803e124d 100644 --- a/src/FluentCMS.Core/Schema/IFieldType.cs +++ b/src/FluentCMS.Core/Schema/IFieldType.cs @@ -17,6 +17,10 @@ public interface IFieldType // Nested types, such as the items of a list, are checked through the registry. IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegistry fieldTypes); + // How a value of a field with these settings looks, for the schema document agents read (SCH-06). + // Only called with settings that passed ValidateSettings; the example must satisfy the schema. + FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes); + // What a field of this type points at, so the schema can check that the types and fields exist (SCH-04). // Only called with settings that passed ValidateSettings. IReadOnlyList ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes) diff --git a/src/FluentCMS.Core/Schema/ISchemaDocuments.cs b/src/FluentCMS.Core/Schema/ISchemaDocuments.cs new file mode 100644 index 000000000..8e8502e17 --- /dev/null +++ b/src/FluentCMS.Core/Schema/ISchemaDocuments.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Schema; + +// The schema in a form an agent can write entries from without other input (SCH-06). +public interface ISchemaDocuments +{ + // The JSON Schema for entries of one content type, at its current version. + Task> GetTypeAsync(string slug, CancellationToken cancellationToken = default); + + // Every content type of the site under $defs. + Task GetSiteAsync(CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Schema/SchemaDocument.cs b/src/FluentCMS.Core/Schema/SchemaDocument.cs new file mode 100644 index 000000000..dc714bf05 --- /dev/null +++ b/src/FluentCMS.Core/Schema/SchemaDocument.cs @@ -0,0 +1,155 @@ +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Schema; + +// The schema as agents read it (SCH-06): a JSON Schema (draft 2020-12) for the entries of a content type, with a description and +// an example for every field and one example entry. On an entity type every field is a fact with its metadata (SCH-03). +public static class SchemaDocument +{ + public const string Dialect = "https://json-schema.org/draft/2020-12/schema"; + + private const string FactSentence = "It is a fact: give its value with its source, owner and validity."; + private const string ExampleTime = "2026-01-01T00:00:00Z"; + + public static JsonObject ForType(ContentType type, FieldTypeRegistry fieldTypes) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(fieldTypes); + + var document = Describe(type, fieldTypes); + document.Insert(0, "$schema", Dialect); + return document; + } + + // Every type of the site under $defs, keyed by slug. + public static JsonObject ForSite(IEnumerable types, FieldTypeRegistry fieldTypes) + { + ArgumentNullException.ThrowIfNull(types); + ArgumentNullException.ThrowIfNull(fieldTypes); + + var definitions = new JsonObject(); + foreach (var type in types.OrderBy(type => type.Slug, StringComparer.Ordinal)) + { + definitions[type.Slug] = Describe(type, fieldTypes); + } + + return new JsonObject + { + ["$schema"] = Dialect, + ["$id"] = "urn:fluentcms:schema", + ["title"] = "Content types", + ["description"] = "Every content type of the site. An entry of a type follows the schema under $defs named by the type's slug.", + ["$defs"] = definitions, + }; + } + + private static JsonObject Describe(ContentType type, FieldTypeRegistry fieldTypes) + { + var isEntity = type.Definition.IsEntity; + var properties = new JsonObject(); + var required = new JsonArray(); + var example = new JsonObject(); + + foreach (var field in type.Definition.Fields) + { + var value = fieldTypes.DescribeValue(field.Type, field.Settings); + var schema = isEntity ? Fact(value.Schema) : value.Schema; + var fieldExample = isEntity ? FactExample(value.Example) : value.Example; + + schema["description"] = Sentences(field.Description, value.Description, isEntity ? FactSentence : null); + schema["examples"] = new JsonArray(fieldExample?.DeepClone()); + properties[field.Name] = schema; + example[field.Name] = fieldExample; + + if (field.Required) + { + required.Add(field.Name); + } + } + + return new JsonObject + { + ["$id"] = $"urn:fluentcms:content-type:{type.Slug}:{type.Version}", + ["title"] = type.Name, + ["description"] = Sentences( + type.Definition.Description, + isEntity + ? $"An entity of the type '{type.Slug}', a source of facts: every field carries its source, owner and validity." + : $"An entry of the content type '{type.Slug}'."), + ["type"] = "object", + ["properties"] = properties, + ["required"] = required, + ["additionalProperties"] = false, + ["examples"] = new JsonArray(example), + ["x-fluentcms"] = new JsonObject { ["slug"] = type.Slug, ["version"] = type.Version, ["entity"] = isEntity }, + }; + } + + // Everything but the value may be null, so an agent states that a fact has no source yet instead of leaving it out (ENT-02). + private static JsonObject Fact(JsonObject value) + { + return new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + [FactMetadata.Value] = value, + [FactMetadata.Source] = new JsonObject + { + ["type"] = Strings(["object", "null"]), + ["description"] = "Where the value comes from, or null when it has none yet, which flags the fact as unsourced.", + ["properties"] = new JsonObject + { + [FactMetadata.SourceKind] = new JsonObject { ["enum"] = Strings(FactMetadata.SourceKinds) }, + [FactMetadata.SourceReference] = new JsonObject + { + ["type"] = "string", + ["minLength"] = 1, + ["description"] = "The URL, file, person or system the value comes from.", + }, + }, + ["required"] = Strings([FactMetadata.SourceKind, FactMetadata.SourceReference]), + ["additionalProperties"] = false, + }, + [FactMetadata.Owner] = Nullable("uuid", "The id of the user who owns the fact."), + [FactMetadata.ValidFrom] = Nullable("date-time", "When the value starts to hold."), + [FactMetadata.ValidUntil] = Nullable("date-time", "When the value stops holding."), + [FactMetadata.LastVerified] = Nullable("date-time", "When someone last confirmed the value."), + }, + ["required"] = Strings([FactMetadata.Value, .. FactMetadata.Properties]), + ["additionalProperties"] = false, + }; + } + + private static JsonObject FactExample(JsonNode? value) + { + return new JsonObject + { + [FactMetadata.Value] = value, + [FactMetadata.Source] = new JsonObject + { + [FactMetadata.SourceKind] = "url", + [FactMetadata.SourceReference] = "https://example.com/source", + }, + [FactMetadata.Owner] = FieldValueDescription.ExampleId, + [FactMetadata.ValidFrom] = ExampleTime, + [FactMetadata.ValidUntil] = null, + [FactMetadata.LastVerified] = ExampleTime, + }; + } + + private static JsonObject Nullable(string format, string description) + { + return new JsonObject { ["type"] = Strings(["string", "null"]), ["format"] = format, ["description"] = description }; + } + + private static JsonArray Strings(IEnumerable values) + { + return new JsonArray([.. values.Select(value => (JsonNode?)value)]); + } + + private static string Sentences(params string?[] sentences) + { + return string.Join(" ", sentences.Where(sentence => !string.IsNullOrWhiteSpace(sentence))); + } +} diff --git a/src/FluentCMS.Core/Schema/SchemaDocuments.cs b/src/FluentCMS.Core/Schema/SchemaDocuments.cs new file mode 100644 index 000000000..a7c2fae52 --- /dev/null +++ b/src/FluentCMS.Core/Schema/SchemaDocuments.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Schema; + +internal sealed class SchemaDocuments(IContentTypeDirectory directory, FieldTypeRegistry fieldTypes) : ISchemaDocuments +{ + public async Task> GetTypeAsync(string slug, CancellationToken cancellationToken = default) + { + var type = await directory.GetAsync(slug, cancellationToken); + + return type.IsSuccess + ? Result.Success(SchemaDocument.ForType(type.Value, fieldTypes)) + : Result.Failure(type.Errors); + } + + public async Task GetSiteAsync(CancellationToken cancellationToken = default) + { + var types = new List(); + + foreach (var summary in await directory.ListAsync(cancellationToken)) + { + // A type listed a moment ago is still there: types are never deleted. + types.Add((await directory.GetAsync(summary.Slug, cancellationToken)).Value); + } + + return SchemaDocument.ForSite(types, fieldTypes); + } +} diff --git a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs index 0eec13523..fbcdeb23d 100644 --- a/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs +++ b/src/FluentCMS.Core/Schema/SchemaErrorCodes.cs @@ -21,4 +21,5 @@ public static class SchemaErrorCodes public const string ReferenceTargetNotEntity = "reference_target_not_entity"; public const string ReferenceFieldUnknown = "reference_field_unknown"; public const string FieldReferenced = "field_referenced"; + public const string DescriptionInvalid = "description_invalid"; } diff --git a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj index 8eee8c0ad..c292eb2aa 100644 --- a/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj +++ b/tests/FluentCMS.IntegrationTests/FluentCMS.IntegrationTests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs index 0dc8bc368..3101f6946 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/ContentTypeDirectoryTests.cs @@ -257,29 +257,14 @@ public async Task Of_two_concurrent_revisions_of_one_version_one_wins_and_the_ot public async Task The_reference_scenario_model_can_be_defined(string provider) { await using var scenario = await SchemaScenario.CreateAsync(provider); - (string Slug, bool IsEntity, FieldDefinition[] Fields)[] model = - [ - ("category", true, [Field("name", "text", required: true), Reference("parent", "category")]), - ("region", true, [Field("name", "text", required: true), Field("code", "text", """{"maxLength": 8}""")]), - ("spec", true, [Field("name", "text", required: true), Field("value", "text"), Field("unit", "enum", """{"values": ["mm", "kg", "W"]}""")]), - ("product", true, [.. ProductFields, Reference("category", "category"), References("specs", "spec"), References("regions", "region"), Field("hero", "media")]), - ("product-page", false, [Reference("product", "product"), Reference("price", "product", "price"), Field("headline", "text"), Field("body", "richText")]), - ("category-page", false, [Reference("category", "category"), Field("intro", "richText")]), - ("comparison-page", false, [References("products", "product"), References("prices", "product", "price"), Field("verdict", "richText"), Field("extra", "json")]), - ("blog-post", false, [Field("title", "text", required: true), Field("body", "richText"), Field("publishedOn", "dateTime"), Field("featured", "boolean"), References("related", "product")]), - ]; - - foreach (var (slug, isEntity, fields) in model) - { - var created = isEntity ? await scenario.CreateEntityAsync(slug, fields) : await scenario.CreateAsync(slug, fields); - Assert.True(created.IsSuccess, $"{slug}: {string.Join("; ", created.Errors.Select(error => error.Message))}"); - } + + await scenario.CreateReferenceModelAsync(); Assert.Equal( - model.Select(type => type.Slug).Order(StringComparer.Ordinal), + ReferenceModel.Select(type => type.Slug).Order(StringComparer.Ordinal), (await scenario.RunAsync(directory => directory.ListAsync())).Select(summary => summary.Slug)); - foreach (var (slug, isEntity, _) in model) + foreach (var (slug, isEntity, _) in ReferenceModel) { Assert.Equal(isEntity, (await scenario.RunAsync(directory => directory.GetAsync(slug))).Value.Definition.IsEntity); } diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs new file mode 100644 index 000000000..aef21e874 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaDocumentsTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; +using NJsonSchema; +using static FluentCMS.IntegrationTests.Schema.SchemaScenario; + +namespace FluentCMS.IntegrationTests.Schema; + +// The schema document over stored types (SCH-06): what an agent reads before it writes an entry. +public sealed class SchemaDocumentsTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Every_type_of_the_reference_model_has_a_document_its_example_entry_satisfies(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateReferenceModelAsync(); + + foreach (var (slug, isEntity, _) in ReferenceModel) + { + var document = (await DocumentsAsync(scenario, documents => documents.GetTypeAsync(slug))).Value; + var example = document["examples"]![0]; + + Assert.Equal(isEntity, (bool)document["x-fluentcms"]!["entity"]!); + Assert.Empty(await ErrorsAsync(document, example)); + } + + var product = (await DocumentsAsync(scenario, documents => documents.GetTypeAsync("product"))).Value; + Assert.StartsWith("The list price, before discounts. A number from 0 to 99999.99.", (string?)product["properties"]!["price"]!["description"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_site_document_lists_every_stored_type(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateReferenceModelAsync(); + + var site = await DocumentsAsync(scenario, documents => documents.GetSiteAsync()); + + Assert.Equal( + ReferenceModel.Select(type => type.Slug).Order(StringComparer.Ordinal), + site["$defs"]!.AsObject().Select(definition => definition.Key)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revision_shows_in_the_document_and_an_unknown_slug_is_not_found(string provider) + { + await using var scenario = await SchemaScenario.CreateAsync(provider); + await scenario.CreateAsync("page", Field("title", "text")); + await scenario.ReviseAsync("page", 1, new ContentTypeDefinition([Field("title", "text", description: "Shown in the browser tab.")])); + + var page = (await DocumentsAsync(scenario, documents => documents.GetTypeAsync("page"))).Value; + var missing = await DocumentsAsync(scenario, documents => documents.GetTypeAsync("nothing")); + + Assert.Equal("urn:fluentcms:content-type:page:2", (string?)page["$id"]); + Assert.Equal("Shown in the browser tab. Plain text.", (string?)page["properties"]!["title"]!["description"]); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Assert.Single(missing.Errors).Code); + } + + private static async Task DocumentsAsync(SchemaScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } + + // An independent validator, so the test does not grade the document with the logic that wrote it. + private static async Task> ErrorsAsync(JsonNode document, JsonNode? instance) + { + var schema = await JsonSchema.FromJsonAsync(document.ToJsonString()); + return schema.Validate(instance?.ToJsonString() ?? "null").Select(error => error.ToString()).ToList(); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs index 56ad4ce54..ecab131cf 100644 --- a/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs +++ b/tests/FluentCMS.IntegrationTests/Schema/SchemaScenario.cs @@ -85,9 +85,40 @@ public async ValueTask DisposeAsync() await _database.DisposeAsync(); } - public static FieldDefinition Field(string name, string type, string settings = "{}", bool required = false) - { - return new FieldDefinition(name, type, required, JsonSerializer.Deserialize(settings)); + // The model behind the reference scenario (02-personas-and-scenarios): four entity types and the four page types that use them. + public static readonly (string Slug, bool IsEntity, FieldDefinition[] Fields)[] ReferenceModel = + [ + ("category", true, [Field("name", "text", required: true), Reference("parent", "category")]), + ("region", true, [Field("name", "text", required: true), Field("code", "text", """{"maxLength": 8}""")]), + ("spec", true, [Field("name", "text", required: true), Field("value", "text"), Field("unit", "enum", """{"values": ["mm", "kg", "W"]}""")]), + ("product", true, + [ + Field("name", "text", """{"maxLength": 200}""", required: true), + Field("price", "number", """{"min": 0, "max": 99999.99}""", required: true, description: "The list price, before discounts."), + Field("tags", "list", """{"itemType": "text"}"""), + Reference("category", "category"), + References("specs", "spec"), + References("regions", "region"), + Field("hero", "media"), + ]), + ("product-page", false, [Reference("product", "product"), Reference("price", "product", "price"), Field("headline", "text"), Field("body", "richText")]), + ("category-page", false, [Reference("category", "category"), Field("intro", "richText")]), + ("comparison-page", false, [References("products", "product"), References("prices", "product", "price"), Field("verdict", "richText"), Field("extra", "json")]), + ("blog-post", false, [Field("title", "text", required: true), Field("body", "richText"), Field("publishedOn", "dateTime"), Field("featured", "boolean"), References("related", "product")]), + ]; + + public async Task CreateReferenceModelAsync() + { + foreach (var (slug, isEntity, fields) in ReferenceModel) + { + var created = isEntity ? await CreateEntityAsync(slug, fields) : await CreateAsync(slug, fields); + Assert.True(created.IsSuccess, $"{slug}: {string.Join("; ", created.Errors.Select(error => error.Message))}"); + } + } + + public static FieldDefinition Field(string name, string type, string settings = "{}", bool required = false, string? description = null) + { + return new FieldDefinition(name, type, required, JsonSerializer.Deserialize(settings), description); } public static FieldDefinition Reference(string name, string target, string? field = null) diff --git a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj index e61662ba9..58be6ee11 100644 --- a/tests/FluentCMS.Tests/FluentCMS.Tests.csproj +++ b/tests/FluentCMS.Tests/FluentCMS.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs index 01bd610e0..cf1d13012 100644 --- a/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs +++ b/tests/FluentCMS.Tests/Schema/ContentTypeDefinitionTests.cs @@ -124,13 +124,13 @@ public void Every_structural_problem_is_reported_with_its_path() Assert.All(result.Errors, error => Assert.Equal(SchemaErrorCodes.DefinitionMalformed, error.Code)); Assert.Equal( [ - "$.version is not part of a definition; it has 'entity' and 'fields'.", + "$.version is not part of a definition; it has 'entity', 'description' and 'fields'.", "$.fields[0].type must be a string.", "$.fields[1].name is missing.", "$.fields[2] must be an object.", "$.fields[3].required must be true or false.", "$.fields[3].settings must be an object.", - "$.fields[3].label is not part of a field; a field has name, type, required and settings.", + "$.fields[3].label is not part of a field; a field has name, type, required, description and settings.", "$.fields[4].name must be a string.", "$.fields[4].type is missing.", ], @@ -221,7 +221,7 @@ public void A_field_cannot_opt_out_of_fact_metadata() var result = ContentTypeDefinition.Parse("""{"entity": true, "fields": [{"name": "price", "type": "number", "fact": false}]}"""); Assert.Equal( - "$.fields[0].fact is not part of a field; a field has name, type, required and settings.", + "$.fields[0].fact is not part of a field; a field has name, type, required, description and settings.", Assert.Single(result.Errors).Message); } @@ -244,6 +244,50 @@ public void References_name_the_field_a_reference_targets() definition.References(FieldTypes)); } + [Fact] + public void Descriptions_are_written_and_read_back() + { + var definition = new ContentTypeDefinition( + [new FieldDefinition("price", "number", description: "The list price, before discounts.")], + description: "A sellable item."); + + var json = definition.ToJson(); + var readBack = ContentTypeDefinition.Parse(json).Value; + + Assert.Equal( + """{"entity":false,"description":"A sellable item.","fields":[{"name":"price","type":"number","required":false,"description":"The list price, before discounts.","settings":{}}]}""", + json); + Assert.Equal("A sellable item.", readBack.Description); + Assert.Equal("The list price, before discounts.", Assert.Single(readBack.Fields).Description); + Assert.Null(ContentTypeDefinition.Parse("""{"description": null, "fields": [{"name": "a", "type": "text", "description": null}]}""").Value.Description); + } + + [Fact] + public void A_description_is_a_string() + { + var result = ContentTypeDefinition.Parse("""{"description": 5, "fields": [{"name": "a", "type": "text", "description": true}]}"""); + + Assert.Equal(["$.description must be a string.", "$.fields[0].description must be a string."], result.Errors.Select(error => error.Message)); + } + + [Fact] + public void A_blank_or_too_long_description_is_rejected() + { + var definition = new ContentTypeDefinition( + [new FieldDefinition("price", "number", description: new string('a', ContentTypeDefinition.MaxDescriptionLength + 1))], + description: " "); + + var result = definition.Validate(FieldTypes); + + Assert.Equal( + [ + "The type's description must not be blank and must be at most 1000 characters; leave it out instead.", + "Field 'price': the description must not be blank and must be at most 1000 characters; leave it out instead.", + ], + result.Errors.Select(error => error.Message)); + Assert.All(result.Errors, error => Assert.Equal(SchemaErrorCodes.DescriptionInvalid, error.Code)); + } + [Fact] public void Field_settings_must_be_an_object() { diff --git a/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs b/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs index 9abeb6e94..abac7aa25 100644 --- a/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs +++ b/tests/FluentCMS.Tests/Schema/FactMetadataTests.cs @@ -16,5 +16,6 @@ public void Every_fact_carries_a_source_an_owner_and_its_validity() public void A_source_is_a_url_a_file_a_person_or_a_system() { Assert.Equal(["url", "file", "person", "system"], FactMetadata.SourceKinds); + Assert.Equal(("kind", "reference"), (FactMetadata.SourceKind, FactMetadata.SourceReference)); } } diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs index c681a1521..37c9e48a4 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeConformanceTests.cs @@ -101,6 +101,22 @@ public void Declares_reference_targets_as_a_slug_and_an_optional_field_name() } } + // The schema document hands this example to agents as a valid value (SCH-06). + [Fact] + public async Task Describes_its_values_with_a_schema_its_own_example_satisfies() + { + foreach (var json in AcceptedSettings) + { + var value = Registry.DescribeValue(FieldTypeName, Parse(json)); + var errors = await JsonSchemaValidation.ErrorsAsync(value.Schema, value.Example); + + Assert.False(string.IsNullOrWhiteSpace(value.Description), $"{json} has no description."); + Assert.True( + errors.Count == 0, + $"{json}: {value.Example?.ToJsonString() ?? "null"} does not satisfy {value.Schema.ToJsonString()}: {string.Join("; ", errors)}"); + } + } + // A definition comes from an agent, so a setting can hold anything; the answer is an error, never an exception. [Fact] public void Never_throws_whatever_a_setting_holds() diff --git a/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs b/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs index 0cc2794da..b06461465 100644 --- a/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs +++ b/tests/FluentCMS.Tests/Schema/FieldTypeRegistryTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core; using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; @@ -107,5 +108,10 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi { return []; } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription(new JsonObject(), null, "Anything."); + } } } diff --git a/tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs b/tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs new file mode 100644 index 000000000..d7bbaefe2 --- /dev/null +++ b/tests/FluentCMS.Tests/Schema/JsonSchemaValidation.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; +using NJsonSchema; + +namespace FluentCMS.Tests.Schema; + +// Checks documents with an independent validator, so the tests do not grade the schema with the logic that wrote it. +internal static class JsonSchemaValidation +{ + public static async Task> ErrorsAsync(JsonNode schema, JsonNode? instance) + { + var parsed = await JsonSchema.FromJsonAsync(schema.ToJsonString()); + return parsed.Validate(instance?.ToJsonString() ?? "null").Select(error => error.ToString()).ToList(); + } +} diff --git a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs index 5a7182aab..724467509 100644 --- a/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs +++ b/tests/FluentCMS.Tests/Schema/PluginFieldTypeTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using FluentCMS.Core; using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; @@ -92,6 +93,16 @@ public IReadOnlyList ValidateSettings(JsonElement settings, FieldTypeRegi return reader.Errors(); } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + var stars = new FieldSettingsReader(Name, settings).PositiveInteger("stars") ?? 5; + + return new FieldValueDescription( + new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["maximum"] = stars }, + stars, + $"A rating from 1 to {stars} stars."); + } } public sealed class LinkFieldTypeTests : FieldTypeConformanceTests @@ -143,4 +154,12 @@ public IReadOnlyList ReferenceTargets(JsonElement settings, Fie { return [new ReferenceTarget(settings.GetProperty("to").GetString()!)]; } + + public FieldValueDescription DescribeValue(JsonElement settings, FieldTypeRegistry fieldTypes) + { + return new FieldValueDescription( + new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + FieldValueDescription.ExampleId, + $"The id of an entry of '{settings.GetProperty("to").GetString()}' to see as well."); + } } diff --git a/tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs b/tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs new file mode 100644 index 000000000..5a0eb9117 --- /dev/null +++ b/tests/FluentCMS.Tests/Schema/SchemaDocumentTests.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core; +using FluentCMS.Core.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Schema; + +public sealed class SchemaDocumentTests : IDisposable +{ + private static readonly Guid Site = Guid.NewGuid(); + + private readonly ServiceProvider _provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + + private FieldTypeRegistry FieldTypes => _provider.GetRequiredService(); + + [Fact] + public async Task A_content_type_document_describes_every_field_and_its_example_entry_is_valid() + { + var document = SchemaDocument.ForType(ProductPage(), FieldTypes); + + Assert.Equal(SchemaDocument.Dialect, (string?)document["$schema"]); + Assert.Equal("urn:fluentcms:content-type:product-page:1", (string?)document["$id"]); + Assert.Equal("Product page", (string?)document["title"]); + Assert.Equal("The page that sells one product. An entry of the content type 'product-page'.", (string?)document["description"]); + Assert.False((bool)document["additionalProperties"]!); + Assert.Equal(["product", "headline"], document["required"]!.AsArray().Select(name => (string?)name)); + Assert.Equal( + ["product", "price", "headline", "body", "tags", "publishedOn", "featured", "extra"], + document["properties"]!.AsObject().Select(property => property.Key)); + Assert.Equal( + "The price shown on the page. The id of a 'product' entity; the field shows that entity's 'price' instead of a copy of it.", + (string?)document["properties"]!["price"]!["description"]); + Assert.Equal("Formatted text written as Markdown.", (string?)document["properties"]!["body"]!["description"]); + Assert.Null(document["properties"]!["price"]!["properties"]); + + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(document, ExampleEntry(document))); + } + + [Fact] + public async Task An_entity_type_document_makes_every_field_a_fact() + { + var document = SchemaDocument.ForType(Product(), FieldTypes); + var price = document["properties"]!["price"]!; + + Assert.Equal( + ["value", "source", "owner", "validFrom", "validUntil", "lastVerified"], + price["required"]!.AsArray().Select(name => (string?)name)); + Assert.Equal("number", (string?)price["properties"]!["value"]!["type"]); + Assert.Equal(["url", "file", "person", "system"], price["properties"]!["source"]!["properties"]!["kind"]!["enum"]!.AsArray().Select(kind => (string?)kind)); + Assert.EndsWith("It is a fact: give its value with its source, owner and validity.", (string?)price["description"]); + Assert.True((bool)document["x-fluentcms"]!["entity"]!); + + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(document, ExampleEntry(document))); + } + + // An unsourced fact is a valid entry; it is flagged, not refused (ENT-02). + [Fact] + public async Task A_fact_may_state_that_it_has_no_source_yet() + { + var document = SchemaDocument.ForType(Product(), FieldTypes); + var entry = ExampleEntry(document); + entry["price"]!["source"] = null; + entry["price"]!["owner"] = null; + + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(document, entry)); + } + + [Theory] + [InlineData("product-page", "missing required field")] + [InlineData("product-page", "unknown field")] + [InlineData("product-page", "date that is not a date")] + [InlineData("product-page", "id that is not a uuid")] + [InlineData("product", "value outside the enum")] + [InlineData("product", "fact without its source")] + [InlineData("product", "fact with a bare value")] + [InlineData("product", "source of an unknown kind")] + public async Task An_entry_that_breaks_the_document_fails_validation(string type, string breakage) + { + var document = SchemaDocument.ForType(type == "product" ? Product() : ProductPage(), FieldTypes); + var entry = ExampleEntry(document); + + switch (breakage) + { + case "missing required field": + entry.Remove("headline"); + break; + case "unknown field": + entry["subtitle"] = "Not in the schema"; + break; + case "date that is not a date": + entry["publishedOn"] = "next Tuesday"; + break; + case "id that is not a uuid": + entry["product"] = "laptop-15"; + break; + case "value outside the enum": + entry["currency"]!["value"] = "GBP"; + break; + case "fact without its source": + entry["price"]!.AsObject().Remove("source"); + break; + case "fact with a bare value": + entry["price"] = 1299; + break; + case "source of an unknown kind": + entry["price"]!["source"]!["kind"] = "rumour"; + break; + } + + Assert.NotEmpty(await JsonSchemaValidation.ErrorsAsync(document, entry)); + } + + [Fact] + public async Task The_site_document_holds_every_type_under_its_slug() + { + var document = SchemaDocument.ForSite([ProductPage(), Product()], FieldTypes); + var definitions = document["$defs"]!.AsObject(); + + Assert.Equal(["product", "product-page"], definitions.Select(definition => definition.Key)); + + foreach (var (slug, definition) in definitions) + { + var schema = definition!.DeepClone().AsObject(); + schema.Insert(0, "$schema", SchemaDocument.Dialect); + + Assert.True( + (await JsonSchemaValidation.ErrorsAsync(schema, ExampleEntry(schema))).Count == 0, + $"The example entry of '{slug}' does not satisfy its schema."); + } + } + + [Fact] + public void A_field_type_that_is_no_longer_installed_is_described_as_any_value() + { + var restored = ContentType.Restore( + Guid.NewGuid(), + Site, + "Review", + "review", + 2, + new ContentTypeDefinition([new FieldDefinition("rating", "sample.removed")])); + + var rating = SchemaDocument.ForType(restored, FieldTypes)["properties"]!["rating"]!.AsObject(); + + Assert.Equal(["description", "examples"], rating.Select(property => property.Key)); + Assert.Equal("A value of the field type 'sample.removed', which is not installed.", (string?)rating["description"]); + } + + public void Dispose() + { + _provider.Dispose(); + } + + private static JsonObject ExampleEntry(JsonObject document) + { + return document["examples"]![0]!.DeepClone().AsObject(); + } + + private ContentType Product() + { + return ContentType.Create( + Site, + "Product", + "product", + new ContentTypeDefinition( + [ + new FieldDefinition("name", "text", required: true, settings: Settings("""{"maxLength": 120}""")), + new FieldDefinition("price", "number", required: true, settings: Settings("""{"min": 0, "max": 99999.99}"""), description: "The list price."), + new FieldDefinition("currency", "enum", settings: Settings("""{"values": ["EUR", "USD"]}""")), + new FieldDefinition("hero", "media"), + ], + isEntity: true, + description: "A sellable item."), + FieldTypes).Value; + } + + private ContentType ProductPage() + { + return ContentType.Create( + Site, + "Product page", + "product-page", + new ContentTypeDefinition( + [ + new FieldDefinition("product", "reference", required: true, settings: Settings("""{"target": "product"}""")), + new FieldDefinition("price", "reference", settings: Settings("""{"target": "product", "field": "price"}"""), description: "The price shown on the page."), + new FieldDefinition("headline", "text", required: true, settings: Settings("""{"maxLength": 5}""")), + new FieldDefinition("body", "richText"), + new FieldDefinition("tags", "list", settings: Settings("""{"itemType": "enum", "itemSettings": {"values": ["new", "sale"]}}""")), + new FieldDefinition("publishedOn", "dateTime", settings: Settings("""{"dateOnly": true}""")), + new FieldDefinition("featured", "boolean"), + new FieldDefinition("extra", "json"), + ], + description: "The page that sells one product."), + FieldTypes).Value; + } + + private static JsonElement Settings(string json) + { + return JsonSerializer.Deserialize(json); + } +}