Skip to content

Implement canonical version matching for partial versions in FHIR validation #3217

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

Merged
merged 5 commits into from
Jul 23, 2025
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
36 changes: 36 additions & 0 deletions src/Hl7.Fhir.Base/Model/Canonical.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,42 @@ public void Deconstruct(out string? uri, out string? version, out string? fragme
/// </summary>
public bool HasAnchor => Fragment is not null;

/// <summary>
/// Determines if a resource version matches a query version according to FHIR canonical matching rules.
/// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0").
/// </summary>
/// <param name="resourceVersion">The version of the resource being checked.</param>
/// <param name="queryVersion">The version specified in the canonical URL query.</param>
/// <returns>True if the resource version matches the query version according to FHIR canonical matching rules.</returns>
public static bool MatchesVersion(string? resourceVersion, string queryVersion)
{
// If either version is null or empty, treat as no version specified
if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion))
return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion);

// First try exact match for backwards compatibility and performance
if (resourceVersion == queryVersion)
return true;

// Implement partial version matching according to FHIR canonical matching rules
// The query version should be a prefix of the resource version when split by dots
var resourceParts = resourceVersion!.Split('.');
var queryParts = queryVersion.Split('.');

// Query version cannot have more parts than resource version for partial matching
if (queryParts.Length > resourceParts.Length)
return false;

// Check if all query version parts match the corresponding resource version parts
for (int i = 0; i < queryParts.Length; i++)
{
if (resourceParts[i] != queryParts[i])
return false;
}

return true;
}

private static (string? url, string? version, string? fragment) splitCanonical(string canonical)
{
var (rest, a) = splitOff(canonical, '#');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Hl7.Fhir.Specification.Source
{
using Hl7.Fhir.Model;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -97,7 +98,34 @@ private void add(Resource resource)
///<inheritdoc/>
public Resource? ResolveByCanonicalUri(string uri)
{
return _resources.Where(r => r.Url == uri)?.Select(r => r.Resource).FirstOrDefault();
var canonical = new Canonical(uri);
var canonicalUrl = canonical.Uri;
var version = canonical.Version ?? string.Empty;

// Filter by canonical URL first
var candidateResources = _resources.Where(r => r.Url == canonicalUrl).ToList();

if (!candidateResources.Any())
return null;

// If no version specified, return the first match
if (string.IsNullOrEmpty(version))
{
var firstCandidate = candidateResources.FirstOrDefault();
return firstCandidate.Resource;
}

// Look for exact version match or partial version match
foreach (var candidate in candidateResources)
{
if (candidate.Resource is IVersionableConformanceResource versionableConformance)
{
if (Canonical.MatchesVersion(versionableConformance.Version, version))
return candidate.Resource;
}
}

return null;
}

///<inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public static IEnumerable<ArtifactSummary> FindConformanceResources(this IEnumer
var version = values.Length == 2 ? values[1] : string.Empty;

return summaries.ConformanceResources(modelInfo).Where(r => r.GetConformanceCanonicalUrl() == values[0] &&
(string.IsNullOrEmpty(version) || r.GetConformanceVersion() == version));
(string.IsNullOrEmpty(version) || Canonical.MatchesVersion(r.GetConformanceVersion(), version)));
}

/// <summary>Filter <see cref="ArtifactSummary"/> instances for <see cref="CodeSystem"/> resources with the specified valueSet uri.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public static IEnumerable<ArtifactSummary> FindConformanceResources(this IEnumer
var version = values.Length == 2 ? values[1] : string.Empty;

return summaries.ConformanceResources().Where(r => r.GetConformanceCanonicalUrl() == values[0] &&
(string.IsNullOrEmpty(version) || r.GetConformanceVersion() == version));
(string.IsNullOrEmpty(version) || Canonical.MatchesVersion(r.GetConformanceVersion(), version)));
}

/// <summary>Filter <see cref="ArtifactSummary"/> instances for <see cref="CodeSystem"/> resources with the specified valueSet uri.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2024, Firely (info@fire.ly) and contributors
* See the file CONTRIBUTORS for details.
*
* This file is licensed under the BSD 3-Clause license
* available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE
*/

using Hl7.Fhir.Model;
using Hl7.Fhir.Specification.Source;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Linq;

namespace Hl7.Fhir.Specification.Tests
{
[TestClass]
public class CanonicalVersionMatchingTests
{
private InMemoryResourceResolver CreateTestResolver()
{
var resources = new List<Resource>
{
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/MyProfile",
Version = "1.5.0",
Name = "MyProfile150"
},
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/MyProfile",
Version = "1.5.1",
Name = "MyProfile151"
},
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/MyProfile",
Version = "1.6.0",
Name = "MyProfile160"
},
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/MyProfile",
Version = "2.0.0",
Name = "MyProfile200"
},
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/OtherProfile",
Version = "1.5.0",
Name = "OtherProfile150"
}
};

return new InMemoryResourceResolver(resources);
}

[TestMethod]
public void ExactVersionMatching_ShouldWork()
{
// Arrange
var resolver = CreateTestResolver();

// Act
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5.0");

// Assert
Assert.IsNotNull(result);
Assert.IsInstanceOfType(result, typeof(StructureDefinition));
var sd = (StructureDefinition)result;
Assert.AreEqual("1.5.0", sd.Version);
Assert.AreEqual("MyProfile150", sd.Name);
}

[TestMethod]
public void PartialVersionMatching_ShouldMatchFullVersion()
{
// Arrange
var resolver = CreateTestResolver();

// Act - Query with partial version "1.5" should match "1.5.0" or "1.5.1"
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5");

// Assert
Assert.IsNotNull(result);
Assert.IsInstanceOfType(result, typeof(StructureDefinition));
var sd = (StructureDefinition)result;
// Should match one of the 1.5.x versions
Assert.IsTrue(sd.Version.StartsWith("1.5"), $"Expected version starting with '1.5', but got '{sd.Version}'");
}

[TestMethod]
public void PartialVersionMatching_ShouldNotMatchDifferentMajorMinor()
{
// Arrange
var resolver = CreateTestResolver();

// Act - Query with partial version "1.4" should not match any resource
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.4");

// Assert
Assert.IsNull(result);
}

[TestMethod]
public void PartialVersionMatching_ShouldNotMatchHigherMajorVersion()
{
// Arrange
var resolver = CreateTestResolver();

// Act - Query with partial version "1.5" should not match "2.0.0"
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|2");

// Assert
Assert.IsNotNull(result);
var sd = (StructureDefinition)result;
Assert.AreEqual("2.0.0", sd.Version);
}

[TestMethod]
public void NoVersionSpecified_ShouldMatchAnyVersion()
{
// Arrange
var resolver = CreateTestResolver();

// Act - Query without version should match any version
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile");

// Assert
Assert.IsNotNull(result);
Assert.IsInstanceOfType(result, typeof(StructureDefinition));
// Should match one of the available versions
}

[TestMethod]
public void BackwardsCompatibility_ExistingExactMatching_ShouldStillWork()
{
// Arrange
var resolver = CreateTestResolver();

// Act - Use exact version matching as before
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5.1");

// Assert - Should still get exact match
Assert.IsNotNull(result);
var sd = (StructureDefinition)result;
Assert.AreEqual("1.5.1", sd.Version);
Assert.AreEqual("MyProfile151", sd.Name);
}

[TestMethod]
public void NonExistentResource_ShouldReturnNull()
{
// Arrange
var resolver = CreateTestResolver();

// Act
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/NonExistent|1.0");

// Assert
Assert.IsNull(result);
}

[TestMethod]
public void EmptyVersion_ShouldMatchUnversionedResources()
{
// Arrange
var resources = new List<Resource>
{
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/UnversionedProfile",
Name = "UnversionedProfile"
// Version is null/empty
}
};
var resolver = new InMemoryResourceResolver(resources);

// Act
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/UnversionedProfile");

// Assert
Assert.IsNotNull(result);
var sd = (StructureDefinition)result;
Assert.AreEqual("UnversionedProfile", sd.Name);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Hl7.Fhir.Support;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -363,5 +364,50 @@ public async Tasks.Task TestCanonicalUrlConflicts()
Assert.IsTrue(conflictException);
}

[TestMethod]
public void PartialVersionMatching_ShouldWork()
{
// Arrange - Create test resources with different versions
var resources = new List<Resource>
{
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/TestProfile",
Version = "1.5.0",
Name = "TestProfile150"
},
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/TestProfile",
Version = "1.5.1",
Name = "TestProfile151"
},
new StructureDefinition
{
Url = "http://example.org/StructureDefinition/TestProfile",
Version = "1.6.0",
Name = "TestProfile160"
}
};

var resolver = new InMemoryResourceResolver(resources);

// Act & Assert - Test exact version matching (should still work)
var exactResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.5.0");
Assert.IsNotNull(exactResult);
var exactSd = (StructureDefinition)exactResult;
Assert.AreEqual("1.5.0", exactSd.Version);

// Act & Assert - Test partial version matching (new functionality)
var partialResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.5");
Assert.IsNotNull(partialResult, "Partial version matching should return a result");
var partialSd = (StructureDefinition)partialResult;
Assert.IsTrue(partialSd.Version.StartsWith("1.5"), $"Expected version starting with '1.5', but got '{partialSd.Version}'");

// Act & Assert - Test that wrong partial version returns null
var wrongResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.4");
Assert.IsNull(wrongResult, "Non-matching partial version should return null");
}

}
}