Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContract, TImplementation>()` 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<TContract>` 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 also implements `ReferencedTypes`, which returns the slugs it targets; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, so a plugin type gets the same check 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`. 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

Expand Down
4 changes: 2 additions & 2 deletions src/FluentCMS.Core/Builtin/FieldTypes/ListFieldType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
return reader.Errors();
}

public IReadOnlyList<string> ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes)
public IReadOnlyList<ReferenceTarget> ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes)
{
var itemSettings = settings.TryGetProperty("itemSettings", out var value) && value.ValueKind == JsonValueKind.Object
? value
: FieldDefinition.EmptySettings;

return fieldTypes.ReferencedTypes(settings.GetProperty("itemType").GetString()!, itemSettings);
return fieldTypes.ReferenceTargets(settings.GetProperty("itemType").GetString()!, itemSettings);
}
}
15 changes: 12 additions & 3 deletions src/FluentCMS.Core/Builtin/FieldTypes/ReferenceFieldType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

namespace FluentCMS.Core.Builtin.FieldTypes;

// Points at an entry of the content type named by 'target'. Whether that type exists is checked where types are stored.
// Points at an entry of the content type named by 'target', or with 'field' at one field of it, such as product.price (SCH-04).
// Whether the type and field exist, and that a field target is an entity type, is checked where types are stored.
internal sealed class ReferenceFieldType : IFieldType
{
public string Name => "reference";
Expand All @@ -13,17 +14,25 @@ public IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegi
{
var reader = new FieldSettingsReader(Name, settings);
var target = reader.RequiredString("target");
var field = reader.OptionalString("field");

if (target is not null && !SchemaNames.IsSlug(target))
{
reader.Invalid("target", "must be the slug of a content type, such as 'product'");
}

if (field is not null && !SchemaNames.IsFieldName(field))
{
reader.Invalid("field", "must be the name of a field of the target, such as 'price'");
}

return reader.Errors();
}

public IReadOnlyList<string> ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes)
public IReadOnlyList<ReferenceTarget> ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return [settings.GetProperty("target").GetString()!];
var field = settings.TryGetProperty("field", out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;

return [new ReferenceTarget(settings.GetProperty("target").GetString()!, field)];
}
}
16 changes: 13 additions & 3 deletions src/FluentCMS.Core/Schema/ContentType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,20 @@ public Result<ContentType> Revise(ContentTypeDefinition definition, FieldTypeReg
ArgumentNullException.ThrowIfNull(definition);
ArgumentNullException.ThrowIfNull(fieldTypes);

var validation = definition.Validate(fieldTypes);
var errors = new List<Error>();

// Dropping the flag would strip the source, owner and validity from every fact stored under this type (SCH-03).
if (Definition.IsEntity && !definition.IsEntity)
{
errors.Add(new Error(
SchemaErrorCodes.EntityFlagRemoved,
$"The content type '{Slug}' is an entity type whose fields carry source, owner and validity, so a revision must keep \"entity\": true."));
}

return validation.IsSuccess
errors.AddRange(definition.Validate(fieldTypes).Errors);

return errors.Count == 0
? Result<ContentType>.Success(new ContentType(Id, SiteId, Name, Slug, Version + 1, definition))
: Result<ContentType>.Failure(validation.Errors);
: Result<ContentType>.Failure(errors);
}
}
48 changes: 37 additions & 11 deletions src/FluentCMS.Core/Schema/ContentTypeDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
namespace FluentCMS.Core.Schema;

// The fields of one content type version, stored as one JSON document (SCH-01, ADR-0005):
// {"fields":[{"name":"price","type":"number","required":true,"settings":{"min":0}}]}
// {"entity":true,"fields":[{"name":"price","type":"number","required":true,"settings":{"min":0}}]}
public sealed class ContentTypeDefinition
{
private static readonly JsonDocumentOptions ParseOptions = new() { AllowDuplicateProperties = false };

public ContentTypeDefinition(IEnumerable<FieldDefinition> fields)
public ContentTypeDefinition(IEnumerable<FieldDefinition> fields, bool isEntity = false)
{
ArgumentNullException.ThrowIfNull(fields);

Fields = fields.ToList();
IsEntity = isEntity;
if (Fields.Any(field => field is null))
{
throw new ArgumentException("A definition cannot contain a null field.", nameof(fields));
Expand All @@ -24,6 +25,9 @@ public ContentTypeDefinition(IEnumerable<FieldDefinition> fields)

public IReadOnlyList<FieldDefinition> Fields { get; }

// An entity type is a fact source: every one of its fields carries FactMetadata next to its value (SCH-03).
public bool IsEntity { get; }

// Checks the shape of the document only; what the names, types and settings mean is checked by Validate.
public static Result<ContentTypeDefinition> Parse(string json)
{
Expand All @@ -42,10 +46,10 @@ public static Result<ContentTypeDefinition> Parse(string json)
using (document)
{
var errors = new List<Error>();
var fields = ReadFields(document.RootElement, errors);
var (fields, isEntity) = ReadDocument(document.RootElement, errors);

return errors.Count == 0
? Result<ContentTypeDefinition>.Success(new ContentTypeDefinition(fields))
? Result<ContentTypeDefinition>.Success(new ContentTypeDefinition(fields, isEntity))
: Result<ContentTypeDefinition>.Failure(errors);
}
}
Expand All @@ -56,6 +60,7 @@ public string ToJson()
using (var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
writer.WriteBoolean("entity", IsEntity);
writer.WriteStartArray("fields");

foreach (var field in Fields)
Expand Down Expand Up @@ -111,35 +116,56 @@ public Result Validate(FieldTypeRegistry fieldTypes)
return errors.Count == 0 ? Result.Success() : Result.Failure(errors);
}

public bool HasField(string name)
{
ArgumentNullException.ThrowIfNull(name);
return Fields.Any(field => field.Name == name);
}

// Only meaningful for a definition that passed Validate.
public IReadOnlyList<FieldReference> References(FieldTypeRegistry fieldTypes)
{
ArgumentNullException.ThrowIfNull(fieldTypes);

return Fields
.SelectMany(field => fieldTypes.ReferencedTypes(field.Type, field.Settings).Select(target => new FieldReference(field.Name, target)))
.SelectMany(field => fieldTypes.ReferenceTargets(field.Type, field.Settings).Select(target => new FieldReference(field.Name, target)))
.ToList();
}

private static List<FieldDefinition> ReadFields(JsonElement root, List<Error> errors)
// A document without 'entity' is a plain content type, as every definition stored before the flag existed.
private static (List<FieldDefinition> Fields, bool IsEntity) ReadDocument(JsonElement root, List<Error> errors)
{
var fields = new List<FieldDefinition>();
var isEntity = false;

if (root.ValueKind != JsonValueKind.Object)
{
errors.Add(Malformed("$ must be an object with a 'fields' array."));
return fields;
return (fields, isEntity);
}

foreach (var property in root.EnumerateObject().Where(property => property.Name != "fields"))
foreach (var property in root.EnumerateObject())
{
errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has only 'fields'."));
switch (property.Name)
{
case "fields":
break;
case "entity" when property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False:
isEntity = property.Value.GetBoolean();
break;
case "entity":
errors.Add(Malformed("$.entity must be true or false."));
break;
default:
errors.Add(Malformed($"$.{property.Name} is not part of a definition; it has 'entity' and 'fields'."));
break;
}
}

if (!root.TryGetProperty("fields", out var array) || array.ValueKind != JsonValueKind.Array)
{
errors.Add(Malformed("$.fields must be an array."));
return fields;
return (fields, isEntity);
}

var index = 0;
Expand All @@ -151,7 +177,7 @@ private static List<FieldDefinition> ReadFields(JsonElement root, List<Error> er
}
}

return fields;
return (fields, isEntity);
}

private static FieldDefinition? ReadField(JsonElement element, string path, List<Error> errors)
Expand Down
17 changes: 17 additions & 0 deletions src/FluentCMS.Core/Schema/FactMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace FluentCMS.Core.Schema;

// What every field of an entity type carries next to its value (SCH-03). The schema document (SCH-06) describes these names and
// entity values are checked against them (F-ENT); a fact without a source is kept and flagged unsourced (ENT-02).
public static class FactMetadata
{
public const string Value = "value";
public const string Source = "source";
public const string Owner = "owner";
public const string ValidFrom = "validFrom";
public const string ValidUntil = "validUntil";
public const string LastVerified = "lastVerified";

public static IReadOnlyList<string> Properties { get; } = [Source, Owner, ValidFrom, ValidUntil, LastVerified];

public static IReadOnlyList<string> SourceKinds { get; } = ["url", "file", "person", "system"];
}
4 changes: 2 additions & 2 deletions src/FluentCMS.Core/Schema/FieldReference.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace FluentCMS.Core.Schema;

// A field whose values point at entries of the content type with the target slug.
public sealed record FieldReference(string Field, string Target);
// A field of a definition whose values point at the target.
public sealed record FieldReference(string Field, ReferenceTarget Target);
16 changes: 16 additions & 0 deletions src/FluentCMS.Core/Schema/FieldSettingsReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ public bool Flag(string name)
return null;
}

public string? OptionalString(string name)
{
if (Read(name) is not { } value)
{
return null;
}

if (value.ValueKind == JsonValueKind.String && value.GetString() is { } text && !string.IsNullOrWhiteSpace(text))
{
return text;
}

Invalid(name, "must be a non-empty string");
return null;
}

public IReadOnlyList<string>? RequiredStringList(string name)
{
if (Read(name) is not { } value)
Expand Down
4 changes: 2 additions & 2 deletions src/FluentCMS.Core/Schema/FieldTypeRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ public IReadOnlyList<Error> ValidateSettings(string type, JsonElement settings)
return fieldType.ValidateSettings(settings, this);
}

public IReadOnlyList<string> ReferencedTypes(string type, JsonElement settings)
public IReadOnlyList<ReferenceTarget> ReferenceTargets(string type, JsonElement settings)
{
ArgumentNullException.ThrowIfNull(type);

return Find(type)?.ReferencedTypes(settings, this) ?? [];
return Find(type)?.ReferenceTargets(settings, this) ?? [];
}
}
4 changes: 2 additions & 2 deletions src/FluentCMS.Core/Schema/IFieldType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ public interface IFieldType
// Nested types, such as the items of a list, are checked through the registry.
IReadOnlyList<Error> ValidateSettings(JsonElement settings, FieldTypeRegistry fieldTypes);

// The slugs of the content types a field of this type points at, so the schema can check that they exist.
// 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<string> ReferencedTypes(JsonElement settings, FieldTypeRegistry fieldTypes)
IReadOnlyList<ReferenceTarget> ReferenceTargets(JsonElement settings, FieldTypeRegistry fieldTypes)
{
return [];
}
Expand Down
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/Schema/ReferenceTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace FluentCMS.Core.Schema;

// Entries of the content type with this slug, or one field of them when Field is set, such as product.price (SCH-04).
public sealed record ReferenceTarget(string Type, string? Field = null);
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/Schema/SchemaErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ public static class SchemaErrorCodes
public const string ContentTypeNotFound = "content_type_not_found";
public const string VersionNotFound = "version_not_found";
public const string VersionConflict = "version_conflict";
public const string EntityFlagRemoved = "entity_flag_removed";
public const string ReferenceTargetNotEntity = "reference_target_not_entity";
public const string ReferenceFieldUnknown = "reference_field_unknown";
public const string FieldReferenced = "field_referenced";
}
Loading
Loading