Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added validation for the context of extensions #361

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
86acd68
wip
Kasdejong Sep 12, 2024
7ce749c
Merge branch 'develop' into feature/extension-context-validation
Kasdejong Sep 17, 2024
e638a54
Added tests and finalized implementation for extension context valida…
Kasdejong Sep 18, 2024
fbaeefa
wip
Kasdejong Sep 18, 2024
2b56df4
a bit better
Kasdejong Sep 18, 2024
d6b64c8
more progress, only the slices remain
Kasdejong Sep 19, 2024
fe8f3e4
correct link
Kasdejong Sep 19, 2024
bdc85ba
hopefully fixed slicing
Kasdejong Sep 24, 2024
93e3f52
forgot to commit
Kasdejong Sep 24, 2024
2f9254c
added backboneElement refs to schema snaps
Kasdejong Oct 1, 2024
471fbc1
getting closer...
Kasdejong Oct 1, 2024
e2adeed
it works now!
Kasdejong Oct 2, 2024
4b2bf84
we ended up not needing these IDs
Kasdejong Oct 2, 2024
c9b2a90
changed these back
Kasdejong Oct 2, 2024
5d960c3
Merge branch 'develop' into feature/extension-context-validation
Kasdejong Oct 2, 2024
cc44a90
it seems as though this is more correct. Changed implementation of Ex…
Kasdejong Oct 2, 2024
02113e6
should be last commit, finally!
Kasdejong Oct 2, 2024
4537303
should be last commit, finally!
Kasdejong Oct 2, 2024
4a1bc68
Pushed to wrong branch
Kasdejong Oct 2, 2024
e61ae40
this check is necessary, otherwise it could crash on an extension wit…
Kasdejong Oct 2, 2024
f98c314
removed some unnecessary code
Kasdejong Oct 2, 2024
1e7b2f3
how does this change every time?
Kasdejong Oct 2, 2024
d4a0cb4
oops...
Kasdejong Oct 2, 2024
e0d11b5
applied requested changes
Kasdejong Oct 8, 2024
f866215
public api changes
Kasdejong Oct 8, 2024
541934a
changed implementation for extension type extension context because i…
Kasdejong Oct 8, 2024
7612652
fixed no invariant result showing when the invariant evaluated to false
Kasdejong Oct 8, 2024
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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 TypedContext = Firely.Fhir.Validation.ExtensionContextValidator.TypedContext;

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,22 @@
using Hl7.Fhir.Language.Debugging;
using Hl7.Fhir.Model;
using Hl7.Fhir.Specification.Navigation;
using System.Collections.Generic;
using System.Linq;

namespace Firely.Fhir.Validation.Compilation;

/// <summary>
///
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
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,13 @@ public IEnumerable<IAssertion> Build(ElementDefinitionNavigator nav, ElementConv
if (typeAssertion is not null)
yield return typeAssertion;
}
else
{
if (def.Type.SingleOrDefault()?.Code is { } tc)
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
{
yield return new BaseRef(tc);
}
}
}

private static bool shouldValidateTypeReference(ElementDefinitionNavigator nav)
Expand Down
35 changes: 35 additions & 0 deletions src/Firely.Fhir.Validation/Impl/BaseRef.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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 BaseRef : IAssertion
{
/// <summary>
/// Create a new BaseRef.
/// </summary>
/// <param name="baseRef"></param>
public BaseRef(string baseRef)
{
this.Type = baseRef;
}

/// <summary>
/// The type code for the element when no type reference was created.
/// </summary>
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<BaseRef>().SingleOrDefault()?.Type;
#pragma warning restore CS0618 // Type or member is obsolete
}

return null;
}
}
}
182 changes: 182 additions & 0 deletions src/Firely.Fhir.Validation/Impl/ExtensionContextValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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();
Invariants = invariants.ToList();
}

internal List<TypedContext> Contexts { get; }
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved

internal List<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.Any(c => c.Type == null))
{
return new IssueAssertion(Issue.PROFILE_ELEMENTDEF_INCORRECT,
"Extension context type was not set, but a context was defined. Skipping non-invariant context validation")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we report this in the constructor? Then we can catch it before we ship the validation to the client.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can still validate invariants. What should we do here?

.AsResult(state);
}

if (Contexts.Count > 0 && !Contexts.Any(context => validateContext(input, context, vc, state)))
{
return new IssueAssertion(Issue.CONTENT_INCORRECT_OCCURRENCE,
$"Extension used outside of appropriate contexts. Expected context to be one of: {RenderExpectedContexts}")
.AsResult(state);
}

var failedInvariantResults = Invariants
.Select(inv => runContextInvariant(input, inv, vc, state))
.Where(res => !res.Success)
.ToList();

if (failedInvariantResults.Count != 0)
{
return ResultReport.Combine(
failedInvariantResults
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
.Where(fr => fr.Report is not null)
.Select(fr => fr.Report!)
.Concat(
failedInvariantResults
.Where(fr => fr.Report is null && fr.Success == false)
.Select<InvariantValidator.InvariantResult, ResultReport>(fr =>
new IssueAssertion(
Issue.CONTENT_ELEMENT_FAILS_ERROR_CONSTRAINT,
$"Extension context failed invariant constraint {fr.Invariant}"
).AsResult(state))
).ToList()
);
}

return ResultReport.SUCCESS;
}

private static bool validateContext(IScopedNode input, TypedContext context, ValidationSettings settings, ValidationState state)
{
var contextNode = input.ToScopedNode().Parent ??
throw new InvalidOperationException("No context found while validating the context of an extension. Is your scoped node correct?");
return context.Type switch
{
ContextType.DATATYPE => contextNode.InstanceType == context.Expression,
ContextType.EXTENSION => (contextNode.Parent?.Children("url").SingleOrDefault()?.Value as string ?? "None") == 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 System.InvalidOperationException($"Unknown context type {context.Expression}")
};
}

private static bool validateElementContext(string contextExpression, ValidationState state)
{
var defPath = state.Location.DefinitionPath;
var needsElementAdded = defPath.Current?.Previous is not InvokeProfileEvent;

// if we have a slicing identifier, this is a bit tougher, since the slicing identifier actually defines the element itself, not its context. consider:
// context without slicing: Address
// context with slicing: Address.extension:simpleExtension
var exprHasSlicingIdentifier = contextExpression.Contains(':');

return defPath.MatchesContext(contextExpression);
}

private static InvariantValidator.InvariantResult runContextInvariant(IScopedNode input, string invariant, ValidationSettings vc, ValidationState state)
{
var fhirPathValidator = new FhirPathValidator("ctx-inv", invariant.Replace("%extension", "%%extension"));
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
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>
#pragma warning disable RS0016
public record TypedContext(ContextType? Type, string Expression); // PR TODO: ADD THESE TO PUBLIC API
#pragma warning restore RS0016

/// <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
Loading