Skip to content

Commit 1afe78b

Browse files
authored
Merge pull request #3217 from FirelyTeam/copilot/fix-3216
Implement canonical version matching for partial versions in FHIR validation
2 parents 6352fd3 + a982edc commit 1afe78b

File tree

6 files changed

+302
-3
lines changed

6 files changed

+302
-3
lines changed

src/Hl7.Fhir.Base/Model/Canonical.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,42 @@ public void Deconstruct(out string? uri, out string? version, out string? fragme
132132
/// </summary>
133133
public bool HasAnchor => Fragment is not null;
134134

135+
/// <summary>
136+
/// Determines if a resource version matches a query version according to FHIR canonical matching rules.
137+
/// Supports both exact matching and partial version matching (e.g., "1.5" matches "1.5.0").
138+
/// </summary>
139+
/// <param name="resourceVersion">The version of the resource being checked.</param>
140+
/// <param name="queryVersion">The version specified in the canonical URL query.</param>
141+
/// <returns>True if the resource version matches the query version according to FHIR canonical matching rules.</returns>
142+
public static bool MatchesVersion(string? resourceVersion, string queryVersion)
143+
{
144+
// If either version is null or empty, treat as no version specified
145+
if (string.IsNullOrEmpty(resourceVersion) || string.IsNullOrEmpty(queryVersion))
146+
return string.IsNullOrEmpty(resourceVersion) && string.IsNullOrEmpty(queryVersion);
147+
148+
// First try exact match for backwards compatibility and performance
149+
if (resourceVersion == queryVersion)
150+
return true;
151+
152+
// Implement partial version matching according to FHIR canonical matching rules
153+
// The query version should be a prefix of the resource version when split by dots
154+
var resourceParts = resourceVersion!.Split('.');
155+
var queryParts = queryVersion.Split('.');
156+
157+
// Query version cannot have more parts than resource version for partial matching
158+
if (queryParts.Length > resourceParts.Length)
159+
return false;
160+
161+
// Check if all query version parts match the corresponding resource version parts
162+
for (int i = 0; i < queryParts.Length; i++)
163+
{
164+
if (resourceParts[i] != queryParts[i])
165+
return false;
166+
}
167+
168+
return true;
169+
}
170+
135171
private static (string? url, string? version, string? fragment) splitCanonical(string canonical)
136172
{
137173
var (rest, a) = splitOff(canonical, '#');

src/Hl7.Fhir.Base/Specification/Source/InMemoryResourceResolver.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Hl7.Fhir.Specification.Source
44
{
55
using Hl7.Fhir.Model;
6+
using System;
67
using System.Collections.Generic;
78
using System.Linq;
89
using System.Threading.Tasks;
@@ -97,7 +98,34 @@ private void add(Resource resource)
9798
///<inheritdoc/>
9899
public Resource? ResolveByCanonicalUri(string uri)
99100
{
100-
return _resources.Where(r => r.Url == uri)?.Select(r => r.Resource).FirstOrDefault();
101+
var canonical = new Canonical(uri);
102+
var canonicalUrl = canonical.Uri;
103+
var version = canonical.Version ?? string.Empty;
104+
105+
// Filter by canonical URL first
106+
var candidateResources = _resources.Where(r => r.Url == canonicalUrl).ToList();
107+
108+
if (!candidateResources.Any())
109+
return null;
110+
111+
// If no version specified, return the first match
112+
if (string.IsNullOrEmpty(version))
113+
{
114+
var firstCandidate = candidateResources.FirstOrDefault();
115+
return firstCandidate.Resource;
116+
}
117+
118+
// Look for exact version match or partial version match
119+
foreach (var candidate in candidateResources)
120+
{
121+
if (candidate.Resource is IVersionableConformanceResource versionableConformance)
122+
{
123+
if (Canonical.MatchesVersion(versionableConformance.Version, version))
124+
return candidate.Resource;
125+
}
126+
}
127+
128+
return null;
101129
}
102130

103131
///<inheritdoc/>

src/Hl7.Fhir.Conformance/Specification/Source/Summary/ArtifactSummaryExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public static IEnumerable<ArtifactSummary> FindConformanceResources(this IEnumer
5555
var version = values.Length == 2 ? values[1] : string.Empty;
5656

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

6161
/// <summary>Filter <see cref="ArtifactSummary"/> instances for <see cref="CodeSystem"/> resources with the specified valueSet uri.</summary>

src/Hl7.Fhir.STU3/Specification/Source/Summary/ArtifactSummaryExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public static IEnumerable<ArtifactSummary> FindConformanceResources(this IEnumer
5757
var version = values.Length == 2 ? values[1] : string.Empty;
5858

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

6363
/// <summary>Filter <see cref="ArtifactSummary"/> instances for <see cref="CodeSystem"/> resources with the specified valueSet uri.</summary>
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
* Copyright (c) 2024, Firely (info@fire.ly) and contributors
3+
* See the file CONTRIBUTORS for details.
4+
*
5+
* This file is licensed under the BSD 3-Clause license
6+
* available at https://raw.githubusercontent.com/FirelyTeam/firely-net-sdk/master/LICENSE
7+
*/
8+
9+
using Hl7.Fhir.Model;
10+
using Hl7.Fhir.Specification.Source;
11+
using Microsoft.VisualStudio.TestTools.UnitTesting;
12+
using System.Collections.Generic;
13+
using System.Linq;
14+
15+
namespace Hl7.Fhir.Specification.Tests
16+
{
17+
[TestClass]
18+
public class CanonicalVersionMatchingTests
19+
{
20+
private InMemoryResourceResolver CreateTestResolver()
21+
{
22+
var resources = new List<Resource>
23+
{
24+
new StructureDefinition
25+
{
26+
Url = "http://example.org/StructureDefinition/MyProfile",
27+
Version = "1.5.0",
28+
Name = "MyProfile150"
29+
},
30+
new StructureDefinition
31+
{
32+
Url = "http://example.org/StructureDefinition/MyProfile",
33+
Version = "1.5.1",
34+
Name = "MyProfile151"
35+
},
36+
new StructureDefinition
37+
{
38+
Url = "http://example.org/StructureDefinition/MyProfile",
39+
Version = "1.6.0",
40+
Name = "MyProfile160"
41+
},
42+
new StructureDefinition
43+
{
44+
Url = "http://example.org/StructureDefinition/MyProfile",
45+
Version = "2.0.0",
46+
Name = "MyProfile200"
47+
},
48+
new StructureDefinition
49+
{
50+
Url = "http://example.org/StructureDefinition/OtherProfile",
51+
Version = "1.5.0",
52+
Name = "OtherProfile150"
53+
}
54+
};
55+
56+
return new InMemoryResourceResolver(resources);
57+
}
58+
59+
[TestMethod]
60+
public void ExactVersionMatching_ShouldWork()
61+
{
62+
// Arrange
63+
var resolver = CreateTestResolver();
64+
65+
// Act
66+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5.0");
67+
68+
// Assert
69+
Assert.IsNotNull(result);
70+
Assert.IsInstanceOfType(result, typeof(StructureDefinition));
71+
var sd = (StructureDefinition)result;
72+
Assert.AreEqual("1.5.0", sd.Version);
73+
Assert.AreEqual("MyProfile150", sd.Name);
74+
}
75+
76+
[TestMethod]
77+
public void PartialVersionMatching_ShouldMatchFullVersion()
78+
{
79+
// Arrange
80+
var resolver = CreateTestResolver();
81+
82+
// Act - Query with partial version "1.5" should match "1.5.0" or "1.5.1"
83+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5");
84+
85+
// Assert
86+
Assert.IsNotNull(result);
87+
Assert.IsInstanceOfType(result, typeof(StructureDefinition));
88+
var sd = (StructureDefinition)result;
89+
// Should match one of the 1.5.x versions
90+
Assert.IsTrue(sd.Version.StartsWith("1.5"), $"Expected version starting with '1.5', but got '{sd.Version}'");
91+
}
92+
93+
[TestMethod]
94+
public void PartialVersionMatching_ShouldNotMatchDifferentMajorMinor()
95+
{
96+
// Arrange
97+
var resolver = CreateTestResolver();
98+
99+
// Act - Query with partial version "1.4" should not match any resource
100+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.4");
101+
102+
// Assert
103+
Assert.IsNull(result);
104+
}
105+
106+
[TestMethod]
107+
public void PartialVersionMatching_ShouldNotMatchHigherMajorVersion()
108+
{
109+
// Arrange
110+
var resolver = CreateTestResolver();
111+
112+
// Act - Query with partial version "1.5" should not match "2.0.0"
113+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|2");
114+
115+
// Assert
116+
Assert.IsNotNull(result);
117+
var sd = (StructureDefinition)result;
118+
Assert.AreEqual("2.0.0", sd.Version);
119+
}
120+
121+
[TestMethod]
122+
public void NoVersionSpecified_ShouldMatchAnyVersion()
123+
{
124+
// Arrange
125+
var resolver = CreateTestResolver();
126+
127+
// Act - Query without version should match any version
128+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile");
129+
130+
// Assert
131+
Assert.IsNotNull(result);
132+
Assert.IsInstanceOfType(result, typeof(StructureDefinition));
133+
// Should match one of the available versions
134+
}
135+
136+
[TestMethod]
137+
public void BackwardsCompatibility_ExistingExactMatching_ShouldStillWork()
138+
{
139+
// Arrange
140+
var resolver = CreateTestResolver();
141+
142+
// Act - Use exact version matching as before
143+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/MyProfile|1.5.1");
144+
145+
// Assert - Should still get exact match
146+
Assert.IsNotNull(result);
147+
var sd = (StructureDefinition)result;
148+
Assert.AreEqual("1.5.1", sd.Version);
149+
Assert.AreEqual("MyProfile151", sd.Name);
150+
}
151+
152+
[TestMethod]
153+
public void NonExistentResource_ShouldReturnNull()
154+
{
155+
// Arrange
156+
var resolver = CreateTestResolver();
157+
158+
// Act
159+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/NonExistent|1.0");
160+
161+
// Assert
162+
Assert.IsNull(result);
163+
}
164+
165+
[TestMethod]
166+
public void EmptyVersion_ShouldMatchUnversionedResources()
167+
{
168+
// Arrange
169+
var resources = new List<Resource>
170+
{
171+
new StructureDefinition
172+
{
173+
Url = "http://example.org/StructureDefinition/UnversionedProfile",
174+
Name = "UnversionedProfile"
175+
// Version is null/empty
176+
}
177+
};
178+
var resolver = new InMemoryResourceResolver(resources);
179+
180+
// Act
181+
var result = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/UnversionedProfile");
182+
183+
// Assert
184+
Assert.IsNotNull(result);
185+
var sd = (StructureDefinition)result;
186+
Assert.AreEqual("UnversionedProfile", sd.Name);
187+
}
188+
}
189+
}

src/Hl7.Fhir.Specification.Shared.Tests/Source/ResourceResolverTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Hl7.Fhir.Support;
1414
using Microsoft.VisualStudio.TestTools.UnitTesting;
1515
using System;
16+
using System.Collections.Generic;
1617
using System.Diagnostics;
1718
using System.IO;
1819
using System.Linq;
@@ -363,5 +364,50 @@ public async Tasks.Task TestCanonicalUrlConflicts()
363364
Assert.IsTrue(conflictException);
364365
}
365366

367+
[TestMethod]
368+
public void PartialVersionMatching_ShouldWork()
369+
{
370+
// Arrange - Create test resources with different versions
371+
var resources = new List<Resource>
372+
{
373+
new StructureDefinition
374+
{
375+
Url = "http://example.org/StructureDefinition/TestProfile",
376+
Version = "1.5.0",
377+
Name = "TestProfile150"
378+
},
379+
new StructureDefinition
380+
{
381+
Url = "http://example.org/StructureDefinition/TestProfile",
382+
Version = "1.5.1",
383+
Name = "TestProfile151"
384+
},
385+
new StructureDefinition
386+
{
387+
Url = "http://example.org/StructureDefinition/TestProfile",
388+
Version = "1.6.0",
389+
Name = "TestProfile160"
390+
}
391+
};
392+
393+
var resolver = new InMemoryResourceResolver(resources);
394+
395+
// Act & Assert - Test exact version matching (should still work)
396+
var exactResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.5.0");
397+
Assert.IsNotNull(exactResult);
398+
var exactSd = (StructureDefinition)exactResult;
399+
Assert.AreEqual("1.5.0", exactSd.Version);
400+
401+
// Act & Assert - Test partial version matching (new functionality)
402+
var partialResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.5");
403+
Assert.IsNotNull(partialResult, "Partial version matching should return a result");
404+
var partialSd = (StructureDefinition)partialResult;
405+
Assert.IsTrue(partialSd.Version.StartsWith("1.5"), $"Expected version starting with '1.5', but got '{partialSd.Version}'");
406+
407+
// Act & Assert - Test that wrong partial version returns null
408+
var wrongResult = resolver.ResolveByCanonicalUri("http://example.org/StructureDefinition/TestProfile|1.4");
409+
Assert.IsNull(wrongResult, "Non-matching partial version should return null");
410+
}
411+
366412
}
367413
}

0 commit comments

Comments
 (0)