Skip to content

Commit d01e2c4

Browse files
leogdioncursoragent
andcommitted
Centralize serverErrorCode wire constants in CloudKitServerErrorCode.
Share raw string, status, and summary via a single catalog used by both CloudKitError init and serverErrorDetail, and fix MemberImportVisibility CI failures in the ServerErrorCodes tests. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e73b9a6 commit d01e2c4

7 files changed

Lines changed: 203 additions & 85 deletions

File tree

Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift

Lines changed: 75 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -42,40 +42,47 @@ extension CloudKitError {
4242
/// models a CloudKit `serverErrorCode`; `nil` for all other cases.
4343
///
4444
/// The switch is deliberately exhaustive: adding a case to ``CloudKitError``
45-
/// stops compiling here until the new case is classified.
46-
// swiftlint:disable:next cyclomatic_complexity function_body_length
45+
/// stops compiling here until the new case is classified. Wire strings and
46+
/// status numbers come from ``CloudKitServerErrorCode``'s catalog — never
47+
/// inlined here.
48+
// swiftlint:disable:next cyclomatic_complexity
4749
internal var serverErrorDetail: ServerErrorCodeDetail? {
4850
switch self {
4951
case .accessDenied(let reason):
50-
return Self.detail("ACCESS_DENIED", 403, "access denied", reason)
52+
return ServerErrorCodeDetail(code: .accessDenied, reason: reason)
5153
case .atomicFailure(let reason):
52-
return Self.detail("ATOMIC_ERROR", 400, "atomic batch failure", reason)
54+
return ServerErrorCodeDetail(code: .atomicError, reason: reason)
5355
case .authenticationFailed(let reason):
54-
return Self.detail("AUTHENTICATION_FAILED", 401, "authentication failed", reason)
56+
return ServerErrorCodeDetail(code: .authenticationFailed, reason: reason)
5557
case .authenticationRequired(let reason):
56-
return Self.detail("AUTHENTICATION_REQUIRED", 421, "authentication required", reason)
58+
return ServerErrorCodeDetail(code: .authenticationRequired, reason: reason)
5759
case .badRequest(let reason):
58-
return Self.detail("BAD_REQUEST", 400, "bad request", reason)
60+
return ServerErrorCodeDetail(code: .badRequest, reason: reason)
5961
case .conflict(let reason):
60-
return Self.detail("CONFLICT", 409, "conflict", reason)
62+
return ServerErrorCodeDetail(code: .conflict, reason: reason)
6163
case .exists(let reason):
62-
return Self.detail("EXISTS", 409, "already exists", reason)
64+
return ServerErrorCodeDetail(code: .exists, reason: reason)
6365
case .internalServerError(let reason):
64-
return Self.detail("INTERNAL_ERROR", 500, "internal server error", reason)
66+
return ServerErrorCodeDetail(code: .internalError, reason: reason)
6567
case .notFound(let reason):
66-
return Self.detail("NOT_FOUND", 404, "not found", reason)
68+
return ServerErrorCodeDetail(code: .notFound, reason: reason)
6769
case .quotaExceeded(let reason, _):
68-
return Self.detail("QUOTA_EXCEEDED", 413, "quota exceeded", reason)
70+
return ServerErrorCodeDetail(code: .quotaExceeded, reason: reason)
6971
case .throttled(let reason):
70-
return Self.detail("THROTTLED", 429, "throttled", reason)
72+
return ServerErrorCodeDetail(code: .throttled, reason: reason)
7173
case .tryAgainLater(let reason):
72-
return Self.detail("TRY_AGAIN_LATER", 503, "try again later", reason)
74+
return ServerErrorCodeDetail(code: .tryAgainLater, reason: reason)
7375
case .validatingReferenceError(let reason):
74-
return Self.detail("VALIDATING_REFERENCE_ERROR", 412, "reference validation error", reason)
76+
return ServerErrorCodeDetail(code: .validatingReferenceError, reason: reason)
7577
case .zoneNotFound(let reason):
76-
return Self.detail("ZONE_NOT_FOUND", 404, "zone not found", reason)
78+
return ServerErrorCodeDetail(code: .zoneNotFound, reason: reason)
7779
case .unknownServerError(let code, let statusCode, let reason):
78-
return Self.detail(code, statusCode, "unrecognized server error", reason)
80+
return ServerErrorCodeDetail(
81+
code: code,
82+
statusCode: statusCode,
83+
summary: ServerErrorCodeDetail.unrecognizedSummary,
84+
reason: reason
85+
)
7986
case .httpError, .httpErrorWithDetails, .httpErrorWithRawResponse, .invalidResponse,
8087
.incompleteResponse, .conversionFailed, .recordOperationFailed,
8188
.subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError,
@@ -92,7 +99,7 @@ extension CloudKitError {
9299
/// ``CloudKitError/httpErrorWithDetails(statusCode:reason:)``, preserving
93100
/// the server `reason`.
94101
/// - Each of the fourteen codes documented in `openapi.yaml` becomes its own
95-
/// dedicated case.
102+
/// dedicated case, looked up via ``CloudKitServerErrorCode``'s dictionary.
96103
/// - Anything else becomes
97104
/// ``CloudKitError/unknownServerError(code:statusCode:reason:)`` so a code
98105
/// Apple adds after this release still reaches the caller intact.
@@ -101,56 +108,62 @@ extension CloudKitError {
101108
/// - code: The raw `serverErrorCode` string from the failure body.
102109
/// - statusCode: The HTTP status the failure arrived with.
103110
/// - reason: The server-supplied `reason`, when present.
104-
// swiftlint:disable:next cyclomatic_complexity
105111
internal init(serverErrorCode code: String?, statusCode: Int, reason: String?) {
106112
guard let code else {
107113
self = .httpErrorWithDetails(statusCode: statusCode, reason: reason)
108114
return
109115
}
110-
switch code {
111-
case "ACCESS_DENIED":
112-
self = .accessDenied(reason: reason)
113-
case "ATOMIC_ERROR":
114-
self = .atomicFailure(reason: reason)
115-
case "AUTHENTICATION_FAILED":
116-
self = .authenticationFailed(reason: reason)
117-
case "AUTHENTICATION_REQUIRED":
118-
self = .authenticationRequired(reason: reason)
119-
case "BAD_REQUEST":
120-
self = .badRequest(reason: reason)
121-
case "CONFLICT":
122-
self = .conflict(reason: reason)
123-
case "EXISTS":
124-
self = .exists(reason: reason)
125-
case "INTERNAL_ERROR":
126-
self = .internalServerError(reason: reason)
127-
case "NOT_FOUND":
128-
self = .notFound(reason: reason)
129-
case "QUOTA_EXCEEDED":
130-
// `hint` is enriched later by the calling operation's catch block, which
131-
// is the only place that can see the local request state.
132-
self = .quotaExceeded(reason: reason, hint: nil)
133-
case "THROTTLED":
134-
self = .throttled(reason: reason)
135-
case "TRY_AGAIN_LATER":
136-
self = .tryAgainLater(reason: reason)
137-
case "VALIDATING_REFERENCE_ERROR":
138-
self = .validatingReferenceError(reason: reason)
139-
case "ZONE_NOT_FOUND":
140-
self = .zoneNotFound(reason: reason)
141-
default:
142-
self = .unknownServerError(code: code, statusCode: statusCode, reason: reason)
143-
}
116+
// Dictionary lookup in `CloudKitServerErrorCode.init(rawValue:)` — no
117+
// string switch here. Map the typed enum onto the dedicated case.
118+
self = Self.make(
119+
from: CloudKitServerErrorCode(rawValue: code),
120+
statusCode: statusCode,
121+
reason: reason
122+
)
144123
}
145124

146-
private static func detail(
147-
_ code: String,
148-
_ statusCode: Int,
149-
_ summary: String,
150-
_ reason: String?
151-
) -> ServerErrorCodeDetail {
152-
ServerErrorCodeDetail(
153-
code: code, statusCode: statusCode, summary: summary, reason: reason
154-
)
125+
/// Builds the dedicated case for a typed ``CloudKitServerErrorCode``.
126+
///
127+
/// `hint` for ``CloudKitError/quotaExceeded(reason:hint:)`` is enriched later
128+
/// by the calling operation's catch block, which is the only place that can
129+
/// see the local request state.
130+
// swiftlint:disable:next cyclomatic_complexity
131+
private static func make(
132+
from code: CloudKitServerErrorCode,
133+
statusCode: Int,
134+
reason: String?
135+
) -> CloudKitError {
136+
switch code {
137+
case .accessDenied:
138+
return .accessDenied(reason: reason)
139+
case .atomicError:
140+
return .atomicFailure(reason: reason)
141+
case .authenticationFailed:
142+
return .authenticationFailed(reason: reason)
143+
case .authenticationRequired:
144+
return .authenticationRequired(reason: reason)
145+
case .badRequest:
146+
return .badRequest(reason: reason)
147+
case .conflict:
148+
return .conflict(reason: reason)
149+
case .exists:
150+
return .exists(reason: reason)
151+
case .internalError:
152+
return .internalServerError(reason: reason)
153+
case .notFound:
154+
return .notFound(reason: reason)
155+
case .quotaExceeded:
156+
return .quotaExceeded(reason: reason, hint: nil)
157+
case .throttled:
158+
return .throttled(reason: reason)
159+
case .tryAgainLater:
160+
return .tryAgainLater(reason: reason)
161+
case .validatingReferenceError:
162+
return .validatingReferenceError(reason: reason)
163+
case .zoneNotFound:
164+
return .zoneNotFound(reason: reason)
165+
case .unknown(let raw):
166+
return .unknownServerError(code: raw, statusCode: statusCode, reason: reason)
167+
}
155168
}
156169
}

Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@
3131
///
3232
/// Produced by `CloudKitError.serverErrorDetail` so that a code, the HTTP
3333
/// status Apple documents for it, and the human summary used in error
34-
/// descriptions all live in exactly one place.
34+
/// descriptions all live in exactly one place — ``CloudKitServerErrorCode``'s
35+
/// catalog.
3536
internal struct ServerErrorCodeDetail: Sendable {
37+
/// Summary used when the failure carried an unrecognized `serverErrorCode`.
38+
internal static let unrecognizedSummary = "unrecognized server error"
39+
3640
/// The raw CloudKit `serverErrorCode` string, e.g. `"ACCESS_DENIED"`.
3741
internal let code: String
3842
/// The HTTP status Apple documents for `code`.
@@ -49,4 +53,20 @@ internal struct ServerErrorCodeDetail: Sendable {
4953
self.summary = summary
5054
self.reason = reason
5155
}
56+
57+
/// Builds a detail from a known catalog entry, storing catalog constants at
58+
/// initialization and attaching the server-supplied `reason`.
59+
///
60+
/// Returns `nil` when `code` is ``CloudKitServerErrorCode/unknown(_:)``.
61+
internal init?(code: CloudKitServerErrorCode, reason: String?) {
62+
guard let entry = code.catalogEntry else {
63+
return nil
64+
}
65+
self.init(
66+
code: entry.raw,
67+
statusCode: entry.statusCode,
68+
summary: entry.summary,
69+
reason: reason
70+
)
71+
}
5272
}

Sources/MistKit/Models/CloudKitServerErrorCode.swift

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
/// codes. Mirrors CloudKit's documented `serverErrorCode` values; an
3535
/// ``unknown(_:)`` case carries any code not yet known to this version of
3636
/// MistKit so forward-compatibility never drops information.
37+
///
38+
/// Wire string, documented HTTP status, and human summary for each known code
39+
/// live in ``knownCatalog`` — the single source of truth used by both
40+
/// ``CloudKitError`` construction and ``ServerErrorCodeDetail``.
3741
public enum CloudKitServerErrorCode: Codable, Hashable, Sendable {
3842
case accessDenied
3943
case atomicError
@@ -52,36 +56,104 @@ public enum CloudKitServerErrorCode: Codable, Hashable, Sendable {
5256
/// A server error code not recognized by this version of MistKit.
5357
case unknown(String)
5458

55-
/// The known (case, raw CloudKit string) pairs — the single source of truth
56-
/// for converting in both directions.
57-
private static let knownPairs: [(code: CloudKitServerErrorCode, raw: String)] = [
58-
(.accessDenied, "ACCESS_DENIED"),
59-
(.atomicError, "ATOMIC_ERROR"),
60-
(.authenticationFailed, "AUTHENTICATION_FAILED"),
61-
(.authenticationRequired, "AUTHENTICATION_REQUIRED"),
62-
(.badRequest, "BAD_REQUEST"),
63-
(.conflict, "CONFLICT"),
64-
(.exists, "EXISTS"),
65-
(.internalError, "INTERNAL_ERROR"),
66-
(.notFound, "NOT_FOUND"),
67-
(.quotaExceeded, "QUOTA_EXCEEDED"),
68-
(.throttled, "THROTTLED"),
69-
(.tryAgainLater, "TRY_AGAIN_LATER"),
70-
(.validatingReferenceError, "VALIDATING_REFERENCE_ERROR"),
71-
(.zoneNotFound, "ZONE_NOT_FOUND"),
59+
/// Catalog row for one documented CloudKit `serverErrorCode`.
60+
internal struct CatalogEntry: Sendable {
61+
/// The typed case this row describes.
62+
internal let code: CloudKitServerErrorCode
63+
/// The raw CloudKit wire string, e.g. `"ACCESS_DENIED"`.
64+
internal let raw: String
65+
/// The HTTP status Apple documents for `raw`.
66+
internal let statusCode: Int
67+
/// Lowercase human summary used in error descriptions.
68+
internal let summary: String
69+
}
70+
71+
/// The known catalog — single source of truth for raw string, status, and
72+
/// summary in both directions.
73+
internal static let knownCatalog: [CatalogEntry] = [
74+
CatalogEntry(
75+
code: .accessDenied, raw: "ACCESS_DENIED", statusCode: 403, summary: "access denied"
76+
),
77+
CatalogEntry(
78+
code: .atomicError, raw: "ATOMIC_ERROR", statusCode: 400, summary: "atomic batch failure"
79+
),
80+
CatalogEntry(
81+
code: .authenticationFailed, raw: "AUTHENTICATION_FAILED", statusCode: 401,
82+
summary: "authentication failed"
83+
),
84+
CatalogEntry(
85+
code: .authenticationRequired, raw: "AUTHENTICATION_REQUIRED", statusCode: 421,
86+
summary: "authentication required"
87+
),
88+
CatalogEntry(
89+
code: .badRequest, raw: "BAD_REQUEST", statusCode: 400, summary: "bad request"
90+
),
91+
CatalogEntry(
92+
code: .conflict, raw: "CONFLICT", statusCode: 409, summary: "conflict"
93+
),
94+
CatalogEntry(
95+
code: .exists, raw: "EXISTS", statusCode: 409, summary: "already exists"
96+
),
97+
CatalogEntry(
98+
code: .internalError, raw: "INTERNAL_ERROR", statusCode: 500, summary: "internal server error"
99+
),
100+
CatalogEntry(
101+
code: .notFound, raw: "NOT_FOUND", statusCode: 404, summary: "not found"
102+
),
103+
CatalogEntry(
104+
code: .quotaExceeded, raw: "QUOTA_EXCEEDED", statusCode: 413, summary: "quota exceeded"
105+
),
106+
CatalogEntry(
107+
code: .throttled, raw: "THROTTLED", statusCode: 429, summary: "throttled"
108+
),
109+
CatalogEntry(
110+
code: .tryAgainLater, raw: "TRY_AGAIN_LATER", statusCode: 503, summary: "try again later"
111+
),
112+
CatalogEntry(
113+
code: .validatingReferenceError, raw: "VALIDATING_REFERENCE_ERROR", statusCode: 412,
114+
summary: "reference validation error"
115+
),
116+
CatalogEntry(
117+
code: .zoneNotFound, raw: "ZONE_NOT_FOUND", statusCode: 404, summary: "zone not found"
118+
),
72119
]
73120

121+
/// Lookup from raw CloudKit string → known case.
122+
private static let byRawValue: [String: CloudKitServerErrorCode] = Dictionary(
123+
uniqueKeysWithValues: knownCatalog.map { ($0.raw, $0.code) }
124+
)
125+
126+
/// Lookup from known case → catalog row.
127+
private static let byCode: [CloudKitServerErrorCode: CatalogEntry] = Dictionary(
128+
uniqueKeysWithValues: knownCatalog.map { ($0.code, $0) }
129+
)
130+
74131
/// The raw CloudKit string for this code (e.g. `"NOT_FOUND"`).
75132
public var rawValue: String {
76133
if case .unknown(let raw) = self {
77134
return raw
78135
}
79-
return Self.knownPairs.first { $0.code == self }?.raw ?? ""
136+
return Self.byCode[self]?.raw ?? ""
137+
}
138+
139+
/// The HTTP status Apple documents for this code, or `nil` for ``unknown(_:)``.
140+
public var statusCode: Int? {
141+
Self.byCode[self]?.statusCode
142+
}
143+
144+
/// Lowercase human summary for this code, or `nil` for ``unknown(_:)``.
145+
public var summary: String? {
146+
Self.byCode[self]?.summary
147+
}
148+
149+
/// Catalog row for a known code, or `nil` for ``unknown(_:)``.
150+
internal var catalogEntry: CatalogEntry? {
151+
Self.byCode[self]
80152
}
81153

82154
/// Maps a raw CloudKit string to a known case, or ``unknown(_:)``.
83155
public init(rawValue: String) {
84-
self = Self.knownPairs.first { $0.raw == rawValue }?.code ?? .unknown(rawValue)
156+
self = Self.byRawValue[rawValue] ?? .unknown(rawValue)
85157
}
86158

87159
/// Decodes the code from its raw CloudKit string value.

Sources/MistKit/Models/OperationFailure.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ public struct OperationFailure<Target: OperationFailureTarget>:
9292
) {
9393
let serverErrorCode = ServerErrorCode(rawValue: common.serverErrorCode.rawValue)
9494
// The generated `OperationFailureServerErrorCode` is a closed enum mirroring
95-
// the schema, so a `.unknown` here means our `knownPairs` table drifted
95+
// the schema, so a `.unknown` here means our `knownCatalog` drifted
9696
// from the regenerated schema — assert loudly (test-overridable) while
9797
// still preserving the raw code for forward-compatibility in release.
9898
if case .unknown(let raw) = serverErrorCode {
9999
ConversionFailureReporter.assertionHandler(
100100
"Unmapped CloudKit serverErrorCode \"\(raw)\""
101-
+ " — update CloudKitServerErrorCode.knownPairs",
101+
+ " — update CloudKitServerErrorCode.knownCatalog",
102102
#fileID,
103103
#line
104104
)

Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
// OTHER DEALINGS IN THE SOFTWARE.
2828
//
2929

30+
internal import Foundation
3031
internal import Testing
3132

3233
@testable import MistKit

Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
// OTHER DEALINGS IN THE SOFTWARE.
2828
//
2929

30+
internal import Foundation
3031
internal import Testing
3132

3233
@testable import MistKit

0 commit comments

Comments
 (0)