Skip to content

Commit

Permalink
Merge pull request #361 from FirelyTeam/feature/extension-context-val…
Browse files Browse the repository at this point in the history
…idation

Added validation for the context of extensions
  • Loading branch information
ewoutkramer authored Oct 18, 2024
2 parents 7915e06 + 6c22276 commit f3c2bcc
Show file tree
Hide file tree
Showing 62 changed files with 845 additions and 73 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -348,4 +348,4 @@ MigrationBackup/

# Ionide (cross platform F# VS Code tools) working folder
.ionide/
/.idea/.idea.Firely.Validator.API/.idea/workspace.xml
/.idea/.idea.Firely.Validator.API/.idea/
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Hl7.Fhir.Model;
using Hl7.Fhir.Specification.Navigation;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;


#pragma warning disable CS0618 // Type or member is obsolete
using static Firely.Fhir.Validation.ExtensionContextValidator;

namespace Firely.Fhir.Validation.Compilation
{
internal record CommonExtensionContextComponent(IEnumerable<TypedContext> Contexts,
IEnumerable<string> Invariants)
{

#if STU3
public static bool TryCreate(ElementDefinitionNavigator nav, [NotNullWhen(true)] out CommonExtensionContextComponent? result)
{
var strDef = nav.StructureDefinition;
if (strDef.ContextType is null && !strDef.Context.Any() && !strDef.ContextInvariant.Any()) // if nothing is set, we don't need to validate
{
result = null;
return false;
}

ExtensionContextValidator.ContextType? contextType = strDef.ContextType switch
{
StructureDefinition.ExtensionContext.Resource => ExtensionContextValidator.ContextType.RESOURCE,
StructureDefinition.ExtensionContext.Datatype => ExtensionContextValidator.ContextType.DATATYPE,
StructureDefinition.ExtensionContext.Extension => ExtensionContextValidator.ContextType.EXTENSION,
null => null,
_ => throw new InvalidOperationException($"Unknown context type {strDef.ContextType.Value}")
};

var contexts = strDef.Context.Select(c => new TypedContext(contextType, c));

var invariants = strDef.ContextInvariant;

result = new CommonExtensionContextComponent(contexts, invariants);
return true;
}
#else
public static bool TryCreate(ElementDefinitionNavigator def, [NotNullWhen(true)] out CommonExtensionContextComponent? result)
{
var strDef = def.StructureDefinition;
if (strDef.Context.Count == 0 && !strDef.ContextInvariant.Any()) // if nothing is set, we don't need to validate
{
result = null;
return false;
}

var contexts = strDef.Context.Select<StructureDefinition.ContextComponent, TypedContext>(c =>
new
(
c.Type switch
{
StructureDefinition.ExtensionContextType.Fhirpath => ExtensionContextValidator.ContextType.FHIRPATH,
StructureDefinition.ExtensionContextType.Element => ExtensionContextValidator.ContextType.ELEMENT,
StructureDefinition.ExtensionContextType.Extension => ExtensionContextValidator.ContextType.EXTENSION,
_ => null
},
c.Expression
)
);

var invariants = strDef.ContextInvariant;

result = new CommonExtensionContextComponent(contexts, invariants);
return true;
}
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
<Import_RootNamespace>Firely.Fhir.Validation.Compilation.Shared</Import_RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)CommonExtensionContextComponent.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ElementConversionMode.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Properties\AssemblyInfo.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilderExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\BaseSchemaBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\CardinalityBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\ContentReferenceBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\ExtensionContextBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\FhirPathBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\FhirUriBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SchemaBuilders\FixedBuilder.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Hl7.Fhir.Specification.Navigation;
using System.Collections.Generic;

namespace Firely.Fhir.Validation.Compilation;

#pragma warning disable CS0618 // Type or member is obsolete
internal class ExtensionContextBuilder : ISchemaBuilder
{
public IEnumerable<IAssertion> Build(ElementDefinitionNavigator nav, ElementConversionMode? conversionMode)
{
if (nav is { Path: "Extension", StructureDefinition.Type: "Extension" } && CommonExtensionContextComponent.TryCreate(nav, out var trc))
{
yield return new ExtensionContextValidator(trc.Contexts, trc.Invariants);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public class StandardBuilders(IAsyncResourceResolver source) : ISchemaBuilder
new TypeReferenceBuilder(source),
new CanonicalBuilder(),
new FhirStringBuilder(),
new FhirUriBuilder()
new FhirUriBuilder(),
new ExtensionContextBuilder()
};

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ public IEnumerable<IAssertion> Build(ElementDefinitionNavigator nav, ElementConv
if (typeAssertion is not null)
yield return typeAssertion;
}
else
{
// If we do not validate against the type reference,
// we still need to know the type of the element for the extension context validator,
// so we include it in the schema here
if (def.Type.SingleOrDefault()?.Code is { } typeCode)
{
yield return new BaseType(typeCode);
}
}
}

private static bool shouldValidateTypeReference(ElementDefinitionNavigator nav)
Expand Down
36 changes: 36 additions & 0 deletions src/Firely.Fhir.Validation/Impl/BaseType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Newtonsoft.Json.Linq;
using System.ComponentModel;
using System.Runtime.Serialization;

namespace Firely.Fhir.Validation;

/// <summary>
/// Not an actual assertion. Contains the type code for the element when no type reference was created.
/// </summary>
[DataContract]
[EditorBrowsable(EditorBrowsableState.Never)]
#if NET8_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.Experimental(diagnosticId: "ExperimentalApi")]
#else
[System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")]
#endif
public class BaseType : IAssertion
{
/// <summary>
/// Create a new baseType.
/// </summary>
/// <param name="baseRef"></param>
public BaseType(string baseRef)
{
this.Type = baseRef;
}

/// <summary>
/// The type code for the element when no type reference was created.
/// </summary>
[DataMember]
public string Type { get; }

/// <inheritdoc />
public JToken ToJson() => new JProperty("baseRef", Type);
}
17 changes: 15 additions & 2 deletions src/Firely.Fhir.Validation/Impl/ChildrenValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ ResultReport IValidatable.Validate(IScopedNode input, ValidationSettings vc, Val
m.InstanceElements ?? NOELEMENTS,
vc,
state
.UpdateLocation(vs => vs.ToChild(m.ChildName))
.UpdateLocation(vs => vs.ToChild(m.ChildName, m.TryExtractType()))
.UpdateInstanceLocation(ip => ip.ToChild(m.ChildName, choiceElement(m)))
)) ?? Enumerable.Empty<ResultReport>());

Expand Down Expand Up @@ -225,5 +225,18 @@ internal record MatchResult(List<Match>? Matches, List<IScopedNode>? UnmatchedIn
/// <param name="InstanceElements">Set of elements belong to this child</param>
/// <remarks>Usually, this is the set of elements with the same name and the group of assertions that represents
/// the validation rule for that element generated from the StructureDefinition.</remarks>
internal record Match(string ChildName, IAssertion Assertion, List<IScopedNode>? InstanceElements = null);
internal record Match(string ChildName, IAssertion Assertion, List<IScopedNode>? InstanceElements = null)
{
public string? TryExtractType()
{
if (Assertion is ElementSchema es)
{
#pragma warning disable CS0618 // Type or member is obsolete
return es.Members.OfType<BaseType>().SingleOrDefault()?.Type;
#pragma warning restore CS0618 // Type or member is obsolete
}

return null;
}
}
}
175 changes: 175 additions & 0 deletions src/Firely.Fhir.Validation/Impl/ExtensionContextValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using Hl7.Fhir.ElementModel;
using Hl7.Fhir.Model;
using Hl7.Fhir.Support;
using Hl7.FhirPath;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.Serialization;

namespace Firely.Fhir.Validation;

/// <summary>
/// An assertion which validates the context in which the extension is used against the expected context.
/// </summary>
[DataContract]
[EditorBrowsable(EditorBrowsableState.Never)]
#if NET8_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.Experimental(diagnosticId: "ExperimentalApi")]
#else
[System.Obsolete("This function is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.")]
#endif
public class ExtensionContextValidator : IValidatable
{
/// <summary>
/// Creates a new ExtensionContextValidator with the given allowed contexts and invariants.
/// </summary>
/// <param name="contexts"></param>
/// <param name="invariants"></param>
public ExtensionContextValidator(IEnumerable<TypedContext> contexts, IEnumerable<string> invariants)
{
Contexts = contexts.ToList();

if (Contexts.Any(c => c.Type == null))
{
throw new IncorrectElementDefinitionException("Extension context type was not set, but a context was defined.");
}

Invariants = invariants.ToList();
}

[DataMember] internal IReadOnlyCollection<TypedContext> Contexts { get; }

[DataMember] internal IReadOnlyCollection<string> Invariants { get; }

/// <summary>
/// Validate input against the expected context and invariants.
/// </summary>
/// <param name="input"></param>
/// <param name="vc"></param>
/// <param name="state"></param>
/// <returns></returns>
public ResultReport Validate(IScopedNode input, ValidationSettings vc, ValidationState state)
{
if (Contexts.Count > 0 && !Contexts.Any(context => validateContext(input, context, state)))
{
return new IssueAssertion(Issue.CONTENT_INCORRECT_OCCURRENCE,
$"Extension used outside of appropriate contexts. Expected context to be one of: {RenderExpectedContexts}")
.AsResult(state);
}

var invariantResults = Invariants
.Select(inv => runContextInvariant(input, inv, vc, state))
.ToList();

// fast path for if all invariants are successful
if (invariantResults.All(r => r.Success))
return ResultReport.SUCCESS;

return ResultReport.Combine(
invariantResults.Select<InvariantValidator.InvariantResult, ResultReport>(res =>
(res.Success, res.Report) switch
{
// If eval to false, throw an error
(false, null) =>
new IssueAssertion(
Issue.CONTENT_ELEMENT_FAILS_ERROR_CONSTRAINT,
$"Extension context failed invariant constraint {res.Invariant}").AsResult(state),
// If evalutation threw an exception, return that exception
(_, { } report) => report,
// Otherwise return success
_ => ResultReport.SUCCESS
}
).ToList()
);
}

private static bool validateContext(IScopedNode input, TypedContext context, ValidationState state)
{
var contextNode = input.ToScopedNode().Parent ??
throw new InvalidOperationException("No context found while validating the context of an extension.");
return context.Type switch
{
ContextType.DATATYPE => contextNode.InstanceType == context.Expression,
ContextType.EXTENSION => contextNode.Parent?.InstanceType == "Extension" && (contextNode.Parent?.Children("url").SingleOrDefault()?.Value as string) == context.Expression,
ContextType.FHIRPATH => contextNode.ResourceContext.IsTrue(context.Expression),
ContextType.ELEMENT => validateElementContext(context.Expression, state),
ContextType.RESOURCE => context.Expression == "*" || validateElementContext(context.Expression, state),
_ => throw new InvalidOperationException($"Unknown context type {context.Expression}")
};
}

private static bool validateElementContext(string contextExpression, ValidationState state)
{
var defPath = state.Location.DefinitionPath;

return defPath.MatchesContext(contextExpression);
}

private static InvariantValidator.InvariantResult runContextInvariant(IScopedNode input, string invariant, ValidationSettings vc, ValidationState state)
{
// our invariant is defined with %extension, but the FhirPathValidator expects %%extension because that is our syntax for environment variables
// TODO investigate changing this in the SDK
var fhirPathValidator = new FhirPathValidator("ctx-inv", invariant.Replace("%extension", "%%extension"));
return fhirPathValidator.RunInvariant(input.ToScopedNode().Parent!, vc, state, ("extension", [input.ToScopedNode()]));
}

private string RenderExpectedContexts => string.Join(", ", Contexts.Select(c => $"{{{c.Type},{c.Expression}}}"));

private static string Key => "context";

private object Value =>
new JObject(
new JProperty("context", new JArray(Contexts.Select(c => new JObject(
new JProperty("type", c.Expression),
new JProperty("expression", c.Expression)
)))),
new JProperty("invariants", new JArray(Invariants))
);

/// <inheritdoc />
public JToken ToJson() => new JProperty(Key, Value);

/// <summary>
///
/// </summary>
/// <param name="Type"></param>
/// <param name="Expression"></param>
public record TypedContext(ContextType? Type, string Expression);

/// <summary>
/// The context in which the extension should be used.
/// </summary>
public enum ContextType
{
/// <summary>
/// The context is all elements matching a particular resource element path.
/// </summary>
RESOURCE, // STU3

/// <summary>
/// The context is all nodes matching a particular data type element path (root or repeating element)
/// or all elements referencing aparticular primitive data type (expressed as the datatype name).
/// </summary>
DATATYPE, // STU3

/// <summary>
/// The context is a particular extension from a particular profile, a uri that identifies the extension definition.
/// </summary>
EXTENSION, // STU3+

/// <summary>
/// The context is all elements that match the FHIRPath query found in the expression.
/// </summary>
FHIRPATH, // R4+

/// <summary>
/// The context is any element that has an ElementDefinition.id that matches that found in the expression.
/// This includes ElementDefinition Ids that have slicing identifiers.
/// The full path for the element is [url]#[elementid]. If there is no #, the Element id is one defined in the base specification.
/// </summary>
ELEMENT, // R4+
}
}
Loading

0 comments on commit f3c2bcc

Please sign in to comment.