Skip to content

Commit 0394c67

Browse files
authored
Adding Support for Upload Asset (#232)
1 parent ced6261 commit 0394c67

File tree

77 files changed

+10001
-410
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+10001
-410
lines changed

.claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-runtime-1.9.0-documentation-openapiruntime.md

Lines changed: 5944 additions & 0 deletions
Large diffs are not rendered by default.

.devcontainer/swift-6.2-nightly/devcontainer.json

Lines changed: 0 additions & 40 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "Swift 6.3 Nightly Development Container",
3+
"image": "swift:6.3-nightly-jammy",
4+
"features": {
5+
"ghcr.io/devcontainers/features/common-utils:2": {}
6+
},
7+
"customizations": {
8+
"vscode": {
9+
"extensions": [
10+
"sswg.swift-lang"
11+
]
12+
}
13+
},
14+
"postCreateCommand": "swift --version"
15+
}

CLAUDE.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,32 @@ swift run mistdemo --config-file ~/.mistdemo/config.json query
9797

9898
## Architecture Considerations
9999

100+
### FieldValue Type Architecture
101+
102+
MistKit uses separate types for requests and responses at the OpenAPI schema level to accurately model CloudKit's asymmetric API behavior:
103+
104+
**Type Layers:**
105+
1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (Sources/MistKit/FieldValue.swift)
106+
2. **API Request Layer**: `FieldValueRequest` - No type field, CloudKit infers type from value structure
107+
3. **API Response Layer**: `FieldValueResponse` - Optional type field for explicit type information
108+
109+
**Why Separate Request/Response Types?**
110+
- CloudKit API has asymmetric behavior: requests omit type field, responses may include it
111+
- OpenAPI schema accurately models this asymmetry (openapi.yaml:867-920)
112+
- Swift code generation produces type-safe request/response types
113+
- Compiler prevents accidentally using response types in requests
114+
- Cleaner architecture without nil type values in conversion code
115+
116+
**Generated Types:**
117+
- `Components.Schemas.FieldValueRequest` - Used for modify, create, filter operations
118+
- `Components.Schemas.FieldValueResponse` - Used for query, lookup, changes responses
119+
- `Components.Schemas.RecordRequest` - Records in request bodies
120+
- `Components.Schemas.RecordResponse` - Records in response bodies
121+
122+
**Conversion:**
123+
- Request conversion: `Extensions/OpenAPI/Components+FieldValue.swift` converts domain `FieldValue``FieldValueRequest`
124+
- Response conversion: `Service/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue`
125+
100126
### Modern Swift Features to Utilize
101127
- Swift Concurrency (async/await) for all network operations
102128
- Structured concurrency with TaskGroup for parallel operations
@@ -154,6 +180,47 @@ MistKitLogger.logDebug(_:logger:shouldRedact:) // Debug level
154180
- Set `MISTKIT_DISABLE_LOG_REDACTION=1` to disable redaction for debugging
155181
- Tokens, keys, and secrets are automatically masked in logged messages
156182

183+
### Asset Upload Transport Design
184+
185+
**⚠️ CRITICAL WARNING: Transport Separation**
186+
187+
When providing a custom `AssetUploader` implementation:
188+
- **NEVER** use the CloudKit API transport (`ClientTransport`) for asset uploads
189+
- **MUST** use a separate URLSession instance, NOT shared with api.apple-cloudkit.com
190+
- **MUST NOT** share HTTP/2 connections between CloudKit API and CDN hosts
191+
- Custom uploaders should **ONLY** be used for testing or specialized CDN configurations
192+
- Production code should use the default implementation (`URLSession.shared`)
193+
194+
**Why URLSession instead of ClientTransport?**
195+
196+
Asset uploads use `URLSession.shared` directly rather than the injected `ClientTransport` to avoid HTTP/2 connection reuse issues:
197+
198+
1. **Problem:** CloudKit API (api.apple-cloudkit.com) and CDN (cvws.icloud-content.com) are different hosts
199+
2. **HTTP/2 Issue:** Reusing the same HTTP/2 connection for both hosts causes 421 Misdirected Request errors
200+
3. **Solution:** Use separate URLSession for CDN uploads, maintaining distinct connection pools
201+
202+
**Design:**
203+
- `AssetUploader` closure type allows dependency injection for testing
204+
- Default implementation uses `URLSession.shared.upload(_:to:)` with separate connection pool
205+
- Tests provide mock uploader closures without network calls
206+
- Platform-specific: WASI compilation excludes URLSession code via `#if !os(WASI)`
207+
- **CRITICAL:** Custom uploaders must maintain connection pool separation from CloudKit API
208+
209+
**Implementation Details:**
210+
- AssetUploader type: `(Data, URL) async throws -> (statusCode: Int?, data: Data)`
211+
- Defined in: `Sources/MistKit/Core/AssetUploader.swift`
212+
- URLSession extension: `Sources/MistKit/Extensions/URLSession+AssetUpload.swift`
213+
- Upload orchestration: `Sources/MistKit/Service/CloudKitService+WriteOperations.swift`
214+
- `uploadAssets()` - Complete two-step upload workflow
215+
- `requestAssetUploadURL()` - Step 1: Get CDN upload URL
216+
- `uploadAssetData()` - Step 2: Upload binary data to CDN
217+
218+
**Future Consideration:**
219+
A `ClientTransport` extension could provide a generic upload method, but would need to:
220+
- Handle connection pooling separately for different hosts
221+
- Provide platform-specific implementations (URLSession, custom transports)
222+
- Maintain the same testability via dependency injection
223+
157224
### CloudKit Web Services Integration
158225
- Base URL: `https://api.apple-cloudkit.com`
159226
- Authentication: API Token + Web Auth Token or Server-to-Server Key Authentication
@@ -171,6 +238,18 @@ MistKitLogger.logDebug(_:logger:shouldRedact:) // Debug level
171238
- Parameterized tests for testing multiple scenarios
172239
- See `testing-enablinganddisabling.md` for Swift Testing patterns
173240

241+
### Asset Upload Testing
242+
243+
**Integration Test Requirements:**
244+
- Verify connection pool separation between CloudKit API and CDN
245+
- Test HTTP/2 connection reuse prevention
246+
- Validate 421 Misdirected Request error handling
247+
- Mock uploaders should simulate realistic HTTP responses
248+
249+
**Test Files:**
250+
- `Tests/MistKitTests/Service/CloudKitServiceUploadTests+*.swift`
251+
- `Tests/MistKitTests/Service/AssetUploadTokenTests.swift`
252+
174253
## Important Implementation Notes
175254

176255
1. **Async/Await First**: All network operations should use async/await, not completion handlers

Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ internal struct VirtualBuddyFetcherTests {
160160
httpVersion: nil,
161161
headerFields: nil
162162
)!
163-
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
163+
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
164164
return (response, data)
165165
}
166166

@@ -198,7 +198,7 @@ internal struct VirtualBuddyFetcherTests {
198198
httpVersion: nil,
199199
headerFields: nil
200200
)!
201-
let data = TestFixtures.virtualBuddyUnsignedResponse.data(using: .utf8)!
201+
let data = Data(TestFixtures.virtualBuddyUnsignedResponse.utf8)
202202
return (response, data)
203203
}
204204

@@ -287,7 +287,7 @@ internal struct VirtualBuddyFetcherTests {
287287
httpVersion: nil,
288288
headerFields: nil
289289
)!
290-
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
290+
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
291291
return (response, data)
292292
}
293293

@@ -339,7 +339,7 @@ internal struct VirtualBuddyFetcherTests {
339339
httpVersion: nil,
340340
headerFields: nil
341341
)!
342-
let data = TestFixtures.virtualBuddyBuildMismatchResponse.data(using: .utf8)!
342+
let data = Data(TestFixtures.virtualBuddyBuildMismatchResponse.utf8)
343343
return (response, data)
344344
}
345345

@@ -483,7 +483,7 @@ internal struct VirtualBuddyFetcherTests {
483483
httpVersion: nil,
484484
headerFields: nil
485485
)!
486-
let invalidJSON = "{ invalid json }".data(using: .utf8)!
486+
let invalidJSON = Data("{ invalid json }".utf8)
487487
return (response, invalidJSON)
488488
}
489489

@@ -517,7 +517,7 @@ internal struct VirtualBuddyFetcherTests {
517517
httpVersion: nil,
518518
headerFields: nil
519519
)!
520-
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
520+
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
521521
return (response, data)
522522
}
523523

@@ -547,7 +547,7 @@ internal struct VirtualBuddyFetcherTests {
547547
httpVersion: nil,
548548
headerFields: nil
549549
)!
550-
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
550+
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
551551
return (response, data)
552552
}
553553

@@ -641,7 +641,7 @@ internal struct VirtualBuddyFetcherTests {
641641
httpVersion: nil,
642642
headerFields: nil
643643
)!
644-
let data = TestFixtures.virtualBuddySignedResponse.data(using: .utf8)!
644+
let data = Data(TestFixtures.virtualBuddySignedResponse.utf8)
645645
return (response, data)
646646
}
647647

@@ -685,7 +685,7 @@ internal struct VirtualBuddyFetcherTests {
685685
httpVersion: nil,
686686
headerFields: nil
687687
)!
688-
let data = TestFixtures.virtualBuddyUnsignedResponse.data(using: .utf8)!
688+
let data = Data(TestFixtures.virtualBuddyUnsignedResponse.utf8)
689689
return (response, data)
690690
}
691691

@@ -731,7 +731,7 @@ internal struct VirtualBuddyFetcherTests {
731731
httpVersion: nil,
732732
headerFields: nil
733733
)!
734-
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
734+
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
735735
return (response, data)
736736
}
737737

Examples/MistDemo/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Examples/MistDemo/Sources/MistDemo/Commands/CreateCommand.swift

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
6565
int64 Integer numbers
6666
double Decimal numbers
6767
timestamp Dates (ISO 8601 or Unix timestamp)
68+
asset Asset URL (from upload-asset command)
6869
6970
EXAMPLES:
7071
@@ -93,10 +94,13 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
9394
6. Table output format:
9495
mistdemo create --field "title:string:Test" --output-format table
9596
97+
7. With asset (after upload-asset):
98+
mistdemo create --field "title:string:My Photo, image:asset:https://cws.icloud-content.com:443/..."
99+
96100
NOTES:
97101
• Record name is auto-generated if not provided
98102
• JSON files auto-detect field types from values
99-
• Use environment variables CLOUDKIT_API_TOKEN and CLOUDKIT_WEBAUTH_TOKEN
103+
• Use environment variables CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN
100104
to avoid repeating tokens
101105
"""
102106

@@ -115,8 +119,8 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
115119
let recordName = config.recordName ?? generateRecordName()
116120

117121
// Convert fields to CloudKit format
118-
let cloudKitFields = try convertFieldsToCloudKit(config.fields)
119-
122+
let cloudKitFields = try config.fields.toCloudKitFields()
123+
120124
// Create the record
121125
// NOTE: Zone support requires enhancements to CloudKitService.createRecord method
122126
let recordInfo = try await client.createRecord(
@@ -140,36 +144,6 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
140144
let randomSuffix = String(Int.random(in: MistDemoConstants.Limits.randomSuffixMin...MistDemoConstants.Limits.randomSuffixMax))
141145
return "\(config.recordType.lowercased())-\(timestamp)-\(randomSuffix)"
142146
}
143-
144-
/// Convert Field array to CloudKit fields dictionary
145-
private func convertFieldsToCloudKit(_ fields: [Field]) throws -> [String: FieldValue] {
146-
var cloudKitFields: [String: FieldValue] = [:]
147-
148-
for field in fields {
149-
do {
150-
let convertedValue = try field.type.convertValue(field.value)
151-
let fieldValue = try convertToFieldValue(convertedValue, type: field.type)
152-
cloudKitFields[field.name] = fieldValue
153-
} catch {
154-
throw CreateError.fieldConversionError(field.name, field.type, field.value, error.localizedDescription)
155-
}
156-
}
157-
158-
return cloudKitFields
159-
}
160-
161-
/// Convert a value to the appropriate FieldValue enum case using the FieldValue extension
162-
private func convertToFieldValue(_ value: Any, type: FieldType) throws -> FieldValue {
163-
guard let fieldValue = FieldValue(value: value, fieldType: type) else {
164-
throw CreateError.fieldConversionError(
165-
"",
166-
type,
167-
String(describing: value),
168-
"Unable to convert value to FieldValue"
169-
)
170-
}
171-
return fieldValue
172-
}
173147
}
174148

175149
// CreateError is now defined in Errors/CreateError.swift

0 commit comments

Comments
 (0)