Skip to content

Commit dd0ea55

Browse files
committed
feat: add SdJwtVcData.findByPath for nested and array claim lookup
1 parent fd55427 commit dd0ea55

3 files changed

Lines changed: 210 additions & 52 deletions

File tree

README.md

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -542,67 +542,52 @@ val givenNameDisplay = givenNameClaim.issuerMetadata?.display
542542
Notes:
543543
- `dataElementName` is the ISO mdoc data-element identifier within a namespace.
544544
- `claimName` returns the top-level object key for SD-JWT VC claims; it is `null`
545-
for array indices and the wildcard. When you need to handle those, switch on
546-
`pathElement` directly (`ClaimPathElement.Claim` / `ArrayElement` /
547-
`AllArrayElements`).
548-
- Nested SD-JWT VC claims (e.g. `place_of_birth.country`) are reachable via
549-
`SdJwtVcClaim.children`.
545+
for array indices and the wildcard. Use `pathElement` directly when you need to
546+
handle those (`ClaimPathElement.Claim` / `ArrayElement` / `AllArrayElements`).
550547

551-
##### Nested SD-JWT VC claims
548+
#### Resolving SD-JWT VC claim paths
552549

553-
A nested object such as `place_of_birth = { country, locality, region }` is stored
554-
as one `SdJwtVcClaim` whose `children` list holds the inner claims:
550+
`SdJwtVcData.findByPath` looks up a claim by its full path — useful when the
551+
claim you want is inside a nested object (e.g. `place_of_birth.country`), at a
552+
specific array index (e.g. `nationalities[0]`), or across every element of an
553+
array.
555554

556-
```kotlin
557-
val birthCountry: String? = (pidDocument.data as? SdJwtVcData)?.claims
558-
.orEmpty()
559-
.find { it.claimName == "place_of_birth" }
560-
?.children
561-
?.find { it.claimName == "country" }
562-
?.value as? String
563-
```
564-
565-
The same shape extends to deeper nesting — keep chaining `.children` and matching
566-
by `claimName`.
555+
A path is built from three kinds of elements:
567556

568-
##### SD-JWT VC array elements
557+
- `ClaimPathElement.Claim(name)` — an object key.
558+
- `ClaimPathElement.ArrayElement(i)` — a specific array index.
559+
- `ClaimPathElement.AllArrayElements` — every element of an array (the
560+
`null` wildcard in OpenID4VP claim paths).
569561

570-
An array such as `nationalities = ["GR", "SE"]` is stored as one parent
571-
`SdJwtVcClaim` whose `children` are per-index entries. The convenience property
572-
`arrayIndex` returns the integer when `pathElement` is a
573-
`ClaimPathElement.ArrayElement`:
562+
The function returns one `SdJwtVcClaim` per match, or an empty list if no claim
563+
exists at that path. Paths with a wildcard return one claim per array entry.
574564

575565
```kotlin
576-
// First nationality.
577-
val primaryNationality: String? = (pidDocument.data as? SdJwtVcData)?.claims
578-
.orEmpty()
579-
.find { it.claimName == "nationalities" }
580-
?.children
581-
?.find { it.arrayIndex == 0 }
582-
?.value as? String
583-
584-
// All nationalities.
585-
val nationalities: List<String> = (pidDocument.data as? SdJwtVcData)?.claims
586-
.orEmpty()
587-
.find { it.claimName == "nationalities" }
588-
?.children
589-
?.mapNotNull { it.value as? String }
590-
.orEmpty()
566+
val sdJwtData = (pidDocument.data as? SdJwtVcData) ?: return
567+
568+
// Nested object: place_of_birth.country → "Greece"
569+
val birthCountry = sdJwtData
570+
.findByPath(ClaimPath(Claim("place_of_birth"), Claim("country")))
571+
.firstOrNull()?.value as? String
572+
573+
// Indexed array: nationalities[0] → "GR"
574+
val primaryNationality = sdJwtData
575+
.findByPath(ClaimPath(Claim("nationalities"), ArrayElement(0)))
576+
.firstOrNull()?.value as? String
577+
578+
// Trailing wildcard: every nationality → ["GR", "SE"]
579+
val nationalities = sdJwtData
580+
.findByPath(ClaimPath(Claim("nationalities"), AllArrayElements))
581+
.mapNotNull { it.value as? String }
582+
583+
// Non-trailing wildcard: city of every address → ["Athens", "Berlin"]
584+
val addressCities = sdJwtData
585+
.findByPath(ClaimPath(Claim("addresses"), AllArrayElements, Claim("city")))
586+
.mapNotNull { it.value as? String }
591587
```
592588

593-
For arrays of objects (e.g. `addresses[0].city`), combine the two: descend into
594-
the indexed child, then into its named grand-child:
595-
596-
```kotlin
597-
val firstAddressCity: String? = (pidDocument.data as? SdJwtVcData)?.claims
598-
.orEmpty()
599-
.find { it.claimName == "addresses" }
600-
?.children
601-
?.find { it.arrayIndex == 0 }
602-
?.children
603-
?.find { it.claimName == "city" }
604-
?.value as? String
605-
```
589+
Each returned node is a full `SdJwtVcClaim` — value, `issuerMetadata`, `children`,
590+
and `selectivelyDisclosable` are all available for rendering.
606591

607592
### Other features
608593

document-manager/src/main/java/eu/europa/ec/eudi/wallet/document/format/DocumentData.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package eu.europa.ec.eudi.wallet.document.format
1818

1919
import eu.europa.ec.eudi.sdjwt.DefaultSdJwtOps
2020
import eu.europa.ec.eudi.sdjwt.DefaultSdJwtOps.recreateClaimsAndDisclosuresPerClaim
21+
import eu.europa.ec.eudi.sdjwt.vc.ClaimPath
2122
import eu.europa.ec.eudi.sdjwt.vc.ClaimPathElement
2223
import eu.europa.ec.eudi.sdjwt.vc.SelectPath.Default.query
2324
import eu.europa.ec.eudi.wallet.document.NameSpace
@@ -248,6 +249,32 @@ data class SdJwtVcData(
248249
}.map { it.toSdJwtVcClaim() }
249250
}
250251

252+
/**
253+
* Resolve a [ClaimPath] against the stored claim tree.
254+
*
255+
* Path elements are matched against [SdJwtVcClaim.pathElement] at each level:
256+
* - [ClaimPathElement.Claim] matches the same object key by name.
257+
* - [ClaimPathElement.ArrayElement] matches the same array index.
258+
* - [ClaimPathElement.AllArrayElements] expands to every array-element child
259+
* at that level (one match per index).
260+
*
261+
* Examples — for a credential carrying
262+
* `nationalities = ["GR", "SE"]` and
263+
* `addresses = [{ city = "Athens" }, { city = "Berlin" }]`:
264+
*
265+
* - `ClaimPath(Claim("given_name"))` → 1 leaf claim with the user's first name.
266+
* - `ClaimPath(Claim("nationalities"), ArrayElement(0))` → 1 leaf (`"GR"`).
267+
* - `ClaimPath(Claim("nationalities"), AllArrayElements)` → 2 leaves (`"GR"`, `"SE"`).
268+
* - `ClaimPath(Claim("addresses"), AllArrayElements, Claim("city"))` → 2 leaves
269+
* (`"Athens"`, `"Berlin"`).
270+
*
271+
* Returns an empty list when no path element matches. The returned nodes carry
272+
* the full [SdJwtVcClaim] (value, issuer metadata, children, selectively-
273+
* disclosable flag) so the caller can render them directly.
274+
*/
275+
fun findByPath(path: ClaimPath): List<SdJwtVcClaim> =
276+
findInTree(claims, path.value)
277+
251278
companion object {
252279
internal val ExcludedIdentifiers = arrayOf(
253280
"cnf",
@@ -260,6 +287,28 @@ data class SdJwtVcData(
260287
}
261288
}
262289

290+
/**
291+
* Walks [remaining] path elements over [nodes], descending into children as needed.
292+
* When `remaining` is empty, returns the current matched [nodes].
293+
*/
294+
private fun findInTree(
295+
nodes: List<SdJwtVcClaim>,
296+
remaining: List<ClaimPathElement>,
297+
): List<SdJwtVcClaim> {
298+
if (remaining.isEmpty()) return nodes
299+
val head = remaining.first()
300+
val tail = remaining.drop(1)
301+
val matched = nodes.filter { it.matches(head) }
302+
return if (tail.isEmpty()) matched
303+
else matched.flatMap { findInTree(it.children, tail) }
304+
}
305+
306+
private fun SdJwtVcClaim.matches(element: ClaimPathElement): Boolean = when (element) {
307+
is ClaimPathElement.Claim -> claimName == element.name
308+
is ClaimPathElement.ArrayElement -> arrayIndex == element.index
309+
ClaimPathElement.AllArrayElements -> pathElement is ClaimPathElement.ArrayElement
310+
}
311+
263312
/**
264313
* Represents a claim of a document in the SdJwtVc format.
265314
*

document-manager/src/test/java/eu/europa/ec/eudi/wallet/document/format/SdJwtVcDataTest.kt

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ package eu.europa.ec.eudi.wallet.document.format
1919
import eu.europa.ec.eudi.sdjwt.DefaultSdJwtOps
2020
import eu.europa.ec.eudi.sdjwt.DefaultSdJwtOps.recreateClaimsAndDisclosuresPerClaim
2121
import eu.europa.ec.eudi.sdjwt.SdJwt
22+
import eu.europa.ec.eudi.sdjwt.vc.ClaimPath
23+
import eu.europa.ec.eudi.sdjwt.vc.ClaimPathElement
2224
import eu.europa.ec.eudi.wallet.document.getResourceAsText
2325
import eu.europa.ec.eudi.wallet.document.metadata.IssuerMetadata
2426
import kotlinx.serialization.json.JsonObject
2527
import kotlin.test.BeforeTest
2628
import kotlin.test.Test
2729
import kotlin.test.assertEquals
30+
import kotlin.test.assertTrue
2831

2932
class SdJwtVcDataTest {
3033

@@ -72,6 +75,127 @@ class SdJwtVcDataTest {
7275
)
7376
}
7477

78+
/**
79+
* Top-level claim by name returns exactly one entry whose value matches the
80+
* issuer-provided disclosure.
81+
*/
82+
@Test
83+
fun `findByPath returns the single top-level claim by name`() {
84+
val data = sdJwtVcData()
85+
86+
val result = data.findByPath(
87+
ClaimPath(ClaimPathElement.Claim("given_name"))
88+
)
89+
90+
assertEquals(1, result.size, "expected exactly one match for given_name")
91+
assertEquals("Tyler", result.first().value)
92+
}
93+
94+
/**
95+
* A nested object path (`place_of_birth.country`) descends through `children`
96+
* and returns the leaf claim with the inner value.
97+
*/
98+
@Test
99+
fun `findByPath descends into nested object members`() {
100+
val data = sdJwtVcData()
101+
102+
val result = data.findByPath(
103+
ClaimPath(
104+
ClaimPathElement.Claim("place_of_birth"),
105+
ClaimPathElement.Claim("country"),
106+
)
107+
)
108+
109+
assertEquals(1, result.size, "expected one nested leaf for place_of_birth.country")
110+
assertEquals("AT", result.first().value)
111+
}
112+
113+
/**
114+
* Indexed array access (`nationalities[0]`) resolves through the parent's
115+
* `ArrayElement(0)` child.
116+
*/
117+
@Test
118+
fun `findByPath resolves an array element by index`() {
119+
val data = sdJwtVcData()
120+
121+
val result = data.findByPath(
122+
ClaimPath(
123+
ClaimPathElement.Claim("nationalities"),
124+
ClaimPathElement.ArrayElement(0),
125+
)
126+
)
127+
128+
assertEquals(1, result.size, "expected one match for nationalities[0]")
129+
assertEquals("AT", result.first().value)
130+
}
131+
132+
/**
133+
* The trailing `AllArrayElements` wildcard fans out across every array-element
134+
* child. The fixture's `nationalities` array has one entry, so we expect one
135+
* match — and the matched node is the array-element wrapper, not the parent.
136+
*/
137+
@Test
138+
fun `findByPath expands the trailing wildcard to every array element`() {
139+
val data = sdJwtVcData()
140+
141+
val result = data.findByPath(
142+
ClaimPath(
143+
ClaimPathElement.Claim("nationalities"),
144+
ClaimPathElement.AllArrayElements,
145+
)
146+
)
147+
148+
assertEquals(1, result.size, "fixture has 1 nationality entry, got ${result.size}")
149+
assertTrue(
150+
result.all { it.pathElement is ClaimPathElement.ArrayElement },
151+
"expected ArrayElement wrappers, got ${result.map { it.pathElement }}",
152+
)
153+
}
154+
155+
/**
156+
* A path that does not exist anywhere in the credential resolves to the empty
157+
* list — never an exception.
158+
*/
159+
@Test
160+
fun `findByPath returns empty list when nothing matches`() {
161+
val data = sdJwtVcData()
162+
163+
val result = data.findByPath(
164+
ClaimPath(ClaimPathElement.Claim("does_not_exist"))
165+
)
166+
167+
assertTrue(result.isEmpty(), "expected no matches, got $result")
168+
}
169+
170+
/**
171+
* Two-level descent into a different nested object (`address.region`) — sanity
172+
* that the recursive descent works for any object key, not just one fixture
173+
* field.
174+
*/
175+
@Test
176+
fun `findByPath handles deeper nested objects`() {
177+
val data = sdJwtVcData()
178+
179+
val result = data.findByPath(
180+
ClaimPath(
181+
ClaimPathElement.Claim("address"),
182+
ClaimPathElement.Claim("region"),
183+
)
184+
)
185+
186+
assertEquals(1, result.size, "expected one match for address.region")
187+
assertEquals("Lower Austria", result.first().value)
188+
}
189+
190+
/**
191+
* Wrap the same `SdJwtVcData` construction every `findByPath` test uses.
192+
*/
193+
private fun sdJwtVcData() = SdJwtVcData(
194+
format = SdJwtVcFormat(vct = "urn:eu.europa.ec.eudi:pid:1"),
195+
issuerMetadata = metadata,
196+
sdJwtVc = sdJwtVcString,
197+
)
198+
75199
private fun printSdJwtVcClaims(sdJwtVcClaims: List<SdJwtVcClaim>, indent: String = "") {
76200
for (sdJwtVcClaim in sdJwtVcClaims) {
77201
println("$indent- PathElement: ${sdJwtVcClaim.pathElement}")

0 commit comments

Comments
 (0)