Skip to content
Open
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
54 changes: 40 additions & 14 deletions src/TALXIS.Platform.Metadata.Validation/GuidValidator.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using TALXIS.Platform.Metadata;

namespace TALXIS.Platform.Metadata.Validation;

Expand Down Expand Up @@ -57,8 +58,11 @@ private static readonly (string AttributeName, string FilePattern)[] AttributeId
};

private static readonly Dictionary<string, List<string>> IdentityLookup;

private static readonly Dictionary<string, List<string>> AttributeIdentityLookup;

private static readonly HashSet<string> ComponentRootDirectories = new(ComponentDefinitionRegistry.GetAll().Select(d => d.Directory), StringComparer.OrdinalIgnoreCase);

static GuidValidator()
{
IdentityLookup = BuildLookup(IdentityRules);
Expand All @@ -83,6 +87,7 @@ private static Dictionary<string, List<string>> BuildLookup((string Name, string
private struct GuidLocation
{
public string FilePath;
public string RelativePath;
public string ElementName;
public int Line;
public int Column;
Expand Down Expand Up @@ -142,12 +147,13 @@ public IReadOnlyList<ValidationResult> ValidateDirectory(string workspacePath)
for (int i = 0; i < locations.Count; i++)
{
var loc = locations[i];
var locComponent = GetComponentKey(loc.FilePath);
var locComponent = GetComponentIdentity(loc.RelativePath);

var otherFiles = locations
.Where((l, idx) => idx != i &&
!string.Equals(GetComponentKey(l.FilePath), locComponent, StringComparison.OrdinalIgnoreCase))
.Select(l => Path.GetFileName(l.FilePath))
!string.Equals(GetComponentIdentity(l.RelativePath), locComponent, StringComparison.OrdinalIgnoreCase))
.Select(l => l.RelativePath)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

if (otherFiles.Length == 0)
Expand Down Expand Up @@ -189,6 +195,7 @@ private static void ScanElements(XElement element, string filePath, string relat
AddGuid(guidMap, normalized, new GuidLocation
{
FilePath = filePath,
RelativePath = relativePath,
ElementName = $"@{attr.Name.LocalName}",
Line = lineInfo.HasLineInfo() ? lineInfo.LineNumber : 0,
Column = lineInfo.HasLineInfo() ? lineInfo.LinePosition : 0
Expand All @@ -215,6 +222,7 @@ private static void ScanElements(XElement element, string filePath, string relat
AddGuid(guidMap, normalized, new GuidLocation
{
FilePath = filePath,
RelativePath = relativePath,
ElementName = localName,
Line = lineInfo.HasLineInfo() ? lineInfo.LineNumber : 0,
Column = lineInfo.HasLineInfo() ? lineInfo.LinePosition : 0
Expand Down Expand Up @@ -260,10 +268,18 @@ private static bool MatchesLookup(Dictionary<string, List<string>> lookup, strin
if (patterns.Count == 0)
return true;

var segments = filePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

foreach (var pattern in patterns)
{
if (filePath.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
var isFilePattern = pattern.IndexOf('.') >= 0;
foreach (var segment in segments)
{
if (isFilePattern
? segment.EndsWith(pattern, StringComparison.OrdinalIgnoreCase)
: segment.Equals(pattern, StringComparison.OrdinalIgnoreCase))
return true;
}
}

return false;
Expand All @@ -277,18 +293,28 @@ private static bool MatchesLookup(Dictionary<string, List<string>> lookup, strin
return null;
}

// Identity key = path with the managed-layer suffix stripped, so {name}.xml and
// {name}_managed.xml map to the same component (never flagged as duplicates).
private static string GetComponentKey(string filePath)
private static string GetComponentIdentity(string relativePath)
{
var directory = Path.GetDirectoryName(filePath) ?? string.Empty;
var nameNoExt = Path.GetFileNameWithoutExtension(filePath);
var ext = Path.GetExtension(filePath);
var segments = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

var anchor = Array.FindIndex(segments, s => ComponentRootDirectories.Contains(s));
var scoped = anchor >= 0 ? segments.Skip(anchor).ToArray() : segments;

if (nameNoExt.EndsWith(ManagedLayerSuffix, StringComparison.OrdinalIgnoreCase))
nameNoExt = nameNoExt.Substring(0, nameNoExt.Length - ManagedLayerSuffix.Length);
if (scoped.Length > 0)
{
var file = scoped[scoped.Length - 1];
var ext = Path.GetExtension(file);
var name = Path.GetFileNameWithoutExtension(file);

if (name.EndsWith(ManagedLayerSuffix, StringComparison.OrdinalIgnoreCase))
{
name = name.Substring(0, name.Length - ManagedLayerSuffix.Length);
}

scoped[scoped.Length - 1] = name + ext;
}

return Path.Combine(directory, nameNoExt + ext);
return string.Join(Path.DirectorySeparatorChar.ToString(), scoped);
}

private static void AddGuid(Dictionary<string, List<GuidLocation>> map, string guid, GuidLocation location)
Expand Down
50 changes: 39 additions & 11 deletions src/TALXIS.Platform.Metadata.Validation/JsonValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,50 @@ namespace TALXIS.Platform.Metadata.Validation;
/// </summary>
public sealed class JsonValidator
{
private readonly List<JSchema> _schemas = new();
private readonly Dictionary<string, JSchema> _routedSchemas = new(StringComparer.OrdinalIgnoreCase);
private readonly List<JSchema> _explicitSchemas = new();
private readonly bool _useRouting;

/// <summary>
/// Creates a validator pre-loaded with all embedded JSON schemas.
/// Creates a validator pre-loaded with all embedded JSON schemas. Each file is
/// validated only against the schema that applies to it (resolved by file name
/// and location); files that match no schema are left untouched.
/// </summary>
public JsonValidator()
{
foreach (var name in SchemaResourceLoader.GetAvailableSchemas().Where(n => n.EndsWith(".json")))
{
using var stream = SchemaResourceLoader.OpenSchema(name);
using var reader = new StreamReader(stream);
_schemas.Add(JSchema.Parse(reader.ReadToEnd()));
var dotIndex = name.IndexOf('.');
var stem = dotIndex > 0 ? name.Substring(0, dotIndex) : name;
_routedSchemas[stem] = JSchema.Parse(reader.ReadToEnd());
}

_useRouting = true;
}

internal JsonValidator(IEnumerable<JSchema> schemas)
{
if (schemas is null)
throw new ArgumentNullException(nameof(schemas));

_schemas.AddRange(schemas);
_explicitSchemas.AddRange(schemas);
_useRouting = false;
}

/// <summary>
/// Validates a JSON file against all loaded schemas.
/// A file passes if it is valid against at least one schema.
/// Validates a JSON file against the schema that applies to it.
/// </summary>
public IReadOnlyList<ValidationResult> ValidateFile(string filePath)
{
if (!File.Exists(filePath))
return new[] { new ValidationResult(ValidationSeverity.Error, $"File not found: {filePath}", filePath, null, null) };

var schemas = _useRouting ? ResolveSchemas(filePath) : _explicitSchemas;
if (schemas.Count == 0)
return System.Array.Empty<ValidationResult>();

JToken token;
try
{
Expand All @@ -62,12 +74,9 @@ public IReadOnlyList<ValidationResult> ValidateFile(string filePath)
};
}

if (_schemas.Count == 0)
return System.Array.Empty<ValidationResult>();

// File passes if valid against ANY schema
// File passes if valid against ANY of the applicable schemas.
var allErrors = new List<ValidationResult>();
foreach (var schema in _schemas)
foreach (var schema in schemas)
{
var schemaErrors = ValidateAgainstSchema(token, schema, filePath);
if (schemaErrors.Count == 0)
Expand All @@ -79,6 +88,25 @@ public IReadOnlyList<ValidationResult> ValidateFile(string filePath)
return allErrors;
}

/// <summary>
/// Returns the embedded schemas that apply to <paramref name="filePath"/> based on
/// its name and location. Returns an empty list for files no schema targets, so
/// non-metadata JSON (package.json, tsconfig.json, settings.json, ...) is skipped.
/// </summary>
private IReadOnlyList<JSchema> ResolveSchemas(string filePath)
{
if (_routedSchemas.TryGetValue("Flow", out var flow) && IsFlowDefinition(filePath))
return new[] { flow };

return System.Array.Empty<JSchema>();
}

private static bool IsFlowDefinition(string filePath)
{
var segments = filePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return segments.Any(s => s.Equals("Workflows", StringComparison.OrdinalIgnoreCase));
}

private static JToken LoadToken(string filePath)
{
using var textReader = File.OpenText(filePath);
Expand Down
55 changes: 55 additions & 0 deletions src/TALXIS.Platform.Metadata.Validation/Schemas/AppAction.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">

<xs:element name="appaction" type="AppActionType" />

<xs:complexType name="AppActionType">
<xs:all>
<xs:element name="appmoduleid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="buttonlabeltext" type="AppActionNodeType" minOccurs="0" />
<xs:element name="buttontooltiptitle" type="AppActionNodeType" minOccurs="0" />
<xs:element name="buttontooltipdescription" type="AppActionNodeType" minOccurs="0" />
<xs:element name="buttonaccessibilitytext" type="AppActionNodeType" minOccurs="0" />
<xs:element name="grouptitle" type="AppActionNodeType" minOccurs="0" />
<xs:element name="context" type="xs:string" minOccurs="0" />
<xs:element name="contextentity" type="AppActionNodeType" minOccurs="0" />
<xs:element name="contextvalue" type="xs:string" minOccurs="0" />
<xs:element name="fonticon" type="xs:string" minOccurs="0" />
<xs:element name="hidden" type="xs:string" minOccurs="0" />
<xs:element name="iscustomizable" type="xs:string" minOccurs="0" />
<xs:element name="isdisabled" type="xs:string" minOccurs="0" />
<xs:element name="isgrouptitlehidden" type="xs:string" minOccurs="0" />
<xs:element name="location" type="xs:string" minOccurs="0" />
<xs:element name="name" type="xs:string" minOccurs="0" />
<xs:element name="onclickeventformulacomponentlibraryid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="onclickeventformulacomponentname" type="xs:string" minOccurs="0" />
<xs:element name="onclickeventformulafunctionname" type="xs:string" minOccurs="0" />
<xs:element name="onclickeventtype" type="xs:string" minOccurs="0" />
<xs:element name="onclickeventjavascriptwebresourceid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="onclickeventjavascriptparameters" type="xs:string" minOccurs="0" />
<xs:element name="onclickeventjavascriptfunctionname" type="xs:string" minOccurs="0" />
<xs:element name="origin" type="xs:string" minOccurs="0" />
<xs:element name="parentappactionid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="sequence" type="xs:string" minOccurs="0" />
<xs:element name="statecode" type="xs:string" minOccurs="0" />
<xs:element name="statuscode" type="xs:string" minOccurs="0" />
<xs:element name="type" type="xs:string" minOccurs="0" />
<xs:element name="webresourceid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="iconwebresourceid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="visibilityformulacomponentlibraryid" type="AppActionNodeType" minOccurs="0" />
<xs:element name="visibilityformulacomponentname" type="xs:string" minOccurs="0" />
<xs:element name="visibilityformulafunctionname" type="xs:string" minOccurs="0" />
<xs:element name="visibilitytype" type="xs:string" minOccurs="0" />
</xs:all>
<xs:attribute name="uniquename" type="xs:string" />
<xs:anyAttribute processContents="skip" />
</xs:complexType>

<xs:complexType name="AppActionNodeType" mixed="true">
<xs:sequence>
<xs:any processContents="skip" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
<xs:anyAttribute processContents="skip" />
</xs:complexType>

</xs:schema>
54 changes: 54 additions & 0 deletions src/TALXIS.Platform.Metadata.Validation/Schemas/CanvasApp.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">

<xs:element name="CanvasApp" type="CanvasAppMetaType" />

<xs:complexType name="CanvasAppMetaType">
<xs:all>
<xs:element name="Name" type="xs:string" minOccurs="0" />
<xs:element name="AppVersion" type="xs:string" minOccurs="0" />
<xs:element name="Status" type="xs:string" minOccurs="0" />
<xs:element name="CreatedByClientVersion" type="xs:string" minOccurs="0" />
<xs:element name="MinClientVersion" type="xs:string" minOccurs="0" />
<xs:element name="Tags" type="xs:string" minOccurs="0" />
<xs:element name="IsCdsUpgraded" type="xs:string" minOccurs="0" />
<xs:element name="GalleryItemId" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="BackgroundColor" type="xs:string" minOccurs="0" />
<xs:element name="DisplayName" type="xs:string" minOccurs="0" />
<xs:element name="Description" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="CommitMessage" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="Publisher" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="AuthorizationReferences" type="xs:string" minOccurs="0" />
<xs:element name="ConnectionReferences" type="xs:string" minOccurs="0" />
<xs:element name="DatabaseReferences" type="xs:string" minOccurs="0" />
<xs:element name="AppComponents" type="xs:string" minOccurs="0" />
<xs:element name="AppComponentDependencies" type="xs:string" minOccurs="0" />
<xs:element name="CanConsumeAppPass" type="xs:string" minOccurs="0" />
<xs:element name="CanvasAppType" type="xs:string" minOccurs="0" />
<xs:element name="BypassConsent" type="xs:string" minOccurs="0" />
<xs:element name="AdminControlBypassConsent" type="xs:string" minOccurs="0" />
<xs:element name="EmbeddedApp" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="IntroducedVersion" type="xs:string" minOccurs="0" />
<xs:element name="CdsDependencies" type="xs:string" minOccurs="0" />
<xs:element name="IsCustomizable" type="xs:string" minOccurs="0" />
<xs:element name="CodeAppPackageUris" type="CodeAppPackageUrisType" minOccurs="0" />
<xs:element name="BackgroundImageUri" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="DocumentUri" type="xs:string" nillable="true" minOccurs="0" />
<xs:element name="AdditionalUris" type="AdditionalUrisType" minOccurs="0" />
</xs:all>
<xs:anyAttribute processContents="skip" />
</xs:complexType>

<xs:complexType name="CodeAppPackageUrisType">
<xs:sequence>
<xs:element name="CodeAppPackageUri" type="xs:string" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>

<xs:complexType name="AdditionalUrisType">
<xs:sequence>
<xs:element name="AdditionalUri" type="xs:string" minOccurs="0" maxOccurs="unbounded" />
</xs:sequence>
</xs:complexType>

</xs:schema>
55 changes: 55 additions & 0 deletions src/TALXIS.Platform.Metadata.Validation/Schemas/Catalog.xsd
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">

<xs:element name="catalog" type="CatalogType" />
<xs:element name="catalogassignments" type="CatalogAssignmentsType" />

<xs:complexType name="CatalogType">
<xs:all>
<xs:element name="description" type="CatalogLabelType" minOccurs="0" />
<xs:element name="displayname" type="CatalogLabelType" minOccurs="0" />
<xs:element name="iscustomizable" type="TrueFalse01Type" minOccurs="0" />
<xs:element name="name" type="xs:string" minOccurs="0" />
<xs:element name="parentcatalogid" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element name="uniquename" type="xs:string" minOccurs="0" />
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="uniquename" type="xs:string" use="required" />
<xs:anyAttribute processContents="skip" />
</xs:complexType>

<xs:complexType name="CatalogLabelType">
<xs:sequence>
<xs:element name="label" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="description" type="xs:string" use="required" />
<xs:attribute name="languagecode" type="xs:positiveInteger" use="required" />
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="default" type="xs:string" />
<xs:anyAttribute processContents="skip" />
</xs:complexType>

<xs:complexType name="CatalogAssignmentsType">
<xs:sequence>
<xs:element name="catalogassignment" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:sequence>
<xs:element name="iscustomizable" type="TrueFalse01Type" minOccurs="0" />
</xs:sequence>
<xs:attribute name="catalogid.uniquename" type="xs:string" />
<xs:attribute name="object.uniquename" type="xs:string" />
<xs:attribute name="objectidtype" type="xs:string" />
<xs:anyAttribute processContents="skip" />
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>

</xs:schema>
Loading