Skip to content

Commit f176c53

Browse files
authored
Merge pull request #312 from dsprogramming/feat/structured-wallet-errors
feat: add structured error codes to WalletError for DCQL query failures
2 parents a08abd3 + 8497f4a commit f176c53

File tree

3 files changed

+148
-8
lines changed

3 files changed

+148
-8
lines changed

Sources/EudiWalletKit/Models/WalletError.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,34 @@
1717
import Foundation
1818
/// Wallet error
1919
public struct WalletError: LocalizedError {
20+
/// Structured error code for programmatic handling
21+
public enum Code: String, Sendable {
22+
/// The verifier requested a claim that is not present in the credential
23+
case claimNotFound
24+
/// The verifier requested a credential/document type that is not in the wallet
25+
case credentialNotFound
26+
/// The verifier requested a claim whose value does not match
27+
case claimValueMismatch
28+
/// No claim_set option could be satisfied
29+
case claimSetNotSatisfied
30+
/// A required credential_set cannot be satisfied
31+
case credentialSetNotSatisfied
32+
/// The DCQL query could not be satisfied (general)
33+
case dcqlQueryNotSatisfied
34+
}
35+
2036
public let description: String
2137
public let localizationKey: String?
38+
/// Structured error code for programmatic handling. `nil` for legacy errors.
39+
public let code: Code?
40+
/// Additional context about the error (e.g. claim path, docType).
41+
public let context: [String: String]
2242

23-
public init(description: String, localizationKey: String? = nil) {
43+
public init(description: String, localizationKey: String? = nil, code: Code? = nil, context: [String: String] = [:]) {
2444
self.description = description
2545
self.localizationKey = localizationKey
46+
self.code = code
47+
self.context = context
2648
}
2749

2850
public var errorDescription: String? {

Sources/EudiWalletKit/Services/Openid4VpUtils.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ extension OpenId4VpUtils {
217217
let format = credQuery.dataFormat
218218
// Find matching credentials
219219
let matchingCredIds = queryable.getCredentials(docOrVctType: docType, docDataFormat: format)
220-
if matchingCredIds.isEmpty, dcql.credentialSets == nil { throw WalletError(description: "Credential with docType \(docType) cannot be found.") }
220+
if matchingCredIds.isEmpty, dcql.credentialSets == nil { throw WalletError(description: "Credential with docType \(docType) cannot be found.", code: .credentialNotFound, context: ["docType": docType]) }
221221
// Try to find a credential that satisfies the claim requirements
222222
for credId in matchingCredIds {
223223
do {
@@ -259,7 +259,7 @@ extension OpenId4VpUtils {
259259
}
260260
let isSetRequired = credSet.required ?? CredentialSetQuery.defaultRequiredValue
261261
if isSetRequired, !isSetSatisfied {
262-
throw WalletError(description: "Required credential_set \(credSet.options) cannot be satisfied")
262+
throw WalletError(description: "Required credential_set \(credSet.options) cannot be satisfied", code: .credentialSetNotSatisfied)
263263
}
264264
}
265265
} else {
@@ -270,7 +270,7 @@ extension OpenId4VpUtils {
270270
if result.isEmpty {
271271
let notFoundCred = dcql.credentials.first { c in credentialQueryResults[c.id] == nil }
272272
if let notFoundCred {logger.warning("No credential found matching docType: \(notFoundCred.docType ?? "") with format: \(notFoundCred.format)")}
273-
throw lastError ?? WalletError(description: "DCQL query could not be satisfied")
273+
throw lastError ?? WalletError(description: "DCQL query could not be satisfied", code: .dcqlQueryNotSatisfied)
274274
}
275275
return result
276276
}
@@ -315,7 +315,7 @@ extension OpenId4VpUtils {
315315
} // next claimSetOption
316316
// No claim set option could be satisfied
317317
let claimPathStr = firstMissingClaimInOption?.value.map(\.claimName).joined(separator: "/") ?? "unknown"
318-
throw WalletError(description: "No claim_set option satisfied. First missing claim: \(claimPathStr)")
318+
throw WalletError(description: "No claim_set option satisfied. First missing claim: \(claimPathStr)", code: .claimSetNotSatisfied, context: ["claimPath": claimPathStr])
319319
} else {
320320
// No claim_sets: all claims must be available
321321
var selectedPaths: [ClaimsQuery] = []
@@ -324,11 +324,11 @@ extension OpenId4VpUtils {
324324
if let values = claim.values, !values.isEmpty {
325325
if !queryable.hasClaimWithValue(id: credId, claimPath: claim.path, values: values) {
326326
let claimPathStr = claim.path.value.map(\.claimName).joined(separator: "/")
327-
throw WalletError(description: "Claim value mismatch for: \(claimPathStr)")
327+
throw WalletError(description: "Claim value mismatch for: \(claimPathStr)", code: .claimValueMismatch, context: ["claimPath": claimPathStr])
328328
}
329329
} else if !queryable.hasClaim(id: credId, claimPath: claim.path) {
330330
let claimPathStr = claim.path.value.map(\.claimName).joined(separator: "/")
331-
throw WalletError(description: "Claim not found: \(claimPathStr)")
331+
throw WalletError(description: "Claim not found: \(claimPathStr)", code: .claimNotFound, context: ["claimPath": claimPathStr])
332332
}
333333
selectedPaths.append(claim)
334334
}

Tests/EudiWalletKitTests/DcqlQueryTests.swift

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,125 @@ struct DcqlQueryTests {
616616
#expect(result["pid_cred"]?.count == 4, "Should have all four claims")
617617
}
618618

619-
}
619+
// MARK: - Structured WalletError.Code tests
620620

621+
@Test("WalletError has .claimNotFound code when claim is missing", arguments: ["dcql-vehicle"])
622+
func testErrorCodeClaimNotFound(dcqlFile: String) throws {
623+
let dcqlData = try loadTestResource(fileName: dcqlFile)
624+
let wrapper = try JSONDecoder().decode(DCQL.self, from: dcqlData)
625+
let dcql = try DCQL(credentials: wrapper.credentials)
626+
let dcqlQueryable = DefaultDcqlQueryable(
627+
credentials: ["cred1": ("org.iso.7367.1.mVRC", DocDataFormat.cbor)],
628+
claimPaths: [
629+
"cred1": [
630+
ClaimPath([.claim(name: "org.iso.7367.1"), .claim(name: "vehicle_holder")])
631+
// Missing first_name claim
632+
]
633+
]
634+
)
635+
do {
636+
_ = try OpenId4VpUtils.resolveDcql(dcql, queryable: dcqlQueryable)
637+
Issue.record("Expected WalletError to be thrown")
638+
} catch let error as WalletError {
639+
#expect(error.code == .claimNotFound, "Error code should be .claimNotFound")
640+
#expect(error.context["claimPath"] == "org.iso.18013.5.1/first_name", "Context should contain the missing claim path")
641+
}
642+
}
621643

644+
@Test("WalletError has .credentialNotFound code when docType is missing", arguments: ["dcql-vehicle"])
645+
func testErrorCodeCredentialNotFound(dcqlFile: String) throws {
646+
let dcqlData = try loadTestResource(fileName: dcqlFile)
647+
let wrapper = try JSONDecoder().decode(DCQL.self, from: dcqlData)
648+
let dcql = try DCQL(credentials: wrapper.credentials)
649+
// No credentials at all
650+
let dcqlQueryable = DefaultDcqlQueryable(
651+
credentials: [:],
652+
claimPaths: [:]
653+
)
654+
do {
655+
_ = try OpenId4VpUtils.resolveDcql(dcql, queryable: dcqlQueryable)
656+
Issue.record("Expected WalletError to be thrown")
657+
} catch let error as WalletError {
658+
#expect(error.code == .credentialNotFound, "Error code should be .credentialNotFound")
659+
#expect(error.context["docType"] == "org.iso.7367.1.mVRC", "Context should contain the missing docType")
660+
}
661+
}
622662

663+
@Test("WalletError has .claimValueMismatch code when value doesn't match", arguments: ["dcql-query-values"])
664+
func testErrorCodeClaimValueMismatch(dcqlFile: String) throws {
665+
let dcqlData = try loadTestResource(fileName: dcqlFile)
666+
let dcql = try JSONDecoder().decode(DCQL.self, from: dcqlData)
667+
let dcqlQueryable = DefaultDcqlQueryable(
668+
credentials: [
669+
"pid_cred": ("https://credentials.example.com/identity_credential", DocDataFormat.sdjwt)
670+
],
671+
claimPaths: [
672+
"pid_cred": [
673+
ClaimPath([.claim(name: "last_name")]),
674+
ClaimPath([.claim(name: "first_name")]),
675+
ClaimPath([.claim(name: "address"), .claim(name: "street_address")]),
676+
ClaimPath([.claim(name: "postal_code")])
677+
]
678+
],
679+
claimValues: [
680+
"pid_cred": [
681+
ClaimPath([.claim(name: "last_name")]): ["Smith"], // Wrong value
682+
ClaimPath([.claim(name: "postal_code")]): ["90210"]
683+
]
684+
]
685+
)
686+
do {
687+
_ = try OpenId4VpUtils.resolveDcql(dcql, queryable: dcqlQueryable)
688+
Issue.record("Expected WalletError to be thrown")
689+
} catch let error as WalletError {
690+
#expect(error.code == .claimValueMismatch, "Error code should be .claimValueMismatch")
691+
#expect(error.context["claimPath"] == "last_name", "Context should contain the mismatched claim path")
692+
}
693+
}
694+
695+
@Test("WalletError has .claimSetNotSatisfied code when no claim_set option works", arguments: ["dcql-claim-sets"])
696+
func testErrorCodeClaimSetNotSatisfied(dcqlFile: String) throws {
697+
let dcqlData = try loadTestResource(fileName: dcqlFile)
698+
let dcql = try JSONDecoder().decode(DCQL.self, from: dcqlData)
699+
let dcqlQueryable = DefaultDcqlQueryable(
700+
credentials: [
701+
"pid_cred": ("https://credentials.example.com/identity_credential", DocDataFormat.sdjwt)
702+
],
703+
claimPaths: [
704+
"pid_cred": [
705+
ClaimPath([.claim(name: "last_name")]),
706+
ClaimPath([.claim(name: "date_of_birth")])
707+
// Missing locality, region from set 1
708+
// Missing postal_code from set 2
709+
]
710+
]
711+
)
712+
do {
713+
_ = try OpenId4VpUtils.resolveDcql(dcql, queryable: dcqlQueryable)
714+
Issue.record("Expected WalletError to be thrown")
715+
} catch let error as WalletError {
716+
#expect(error.code == .claimSetNotSatisfied, "Error code should be .claimSetNotSatisfied")
717+
#expect(error.context["claimPath"] != nil, "Context should contain the first missing claim path")
718+
}
719+
}
720+
721+
@Test("WalletError backward compatibility — code and context default to nil and empty")
722+
func testWalletErrorBackwardCompatibility() throws {
723+
let error = WalletError(description: "Some legacy error")
724+
#expect(error.code == nil, "Code should default to nil")
725+
#expect(error.context.isEmpty, "Context should default to empty")
726+
#expect(error.errorDescription == "Some legacy error", "Description should still work")
727+
}
728+
729+
@Test("WalletError with code and context preserves all fields")
730+
func testWalletErrorStructuredFields() throws {
731+
let error = WalletError(
732+
description: "Claim not found: family_name_birth",
733+
code: .claimNotFound,
734+
context: ["claimPath": "family_name_birth"]
735+
)
736+
#expect(error.code == .claimNotFound)
737+
#expect(error.context["claimPath"] == "family_name_birth")
738+
#expect(error.errorDescription == "Claim not found: family_name_birth")
739+
}
740+
}

0 commit comments

Comments
 (0)