Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

40 changes: 0 additions & 40 deletions .devcontainer/swift-6.2-nightly/devcontainer.json

This file was deleted.

15 changes: 15 additions & 0 deletions .devcontainer/swift-6.3-nightly/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "Swift 6.3 Nightly Development Container",
"image": "swift:6.3-nightly-jammy",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {}
},
"customizations": {
"vscode": {
"extensions": [
"sswg.swift-lang"
]
}
},
"postCreateCommand": "swift --version"
}
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ swift run mistdemo --config-file ~/.mistdemo/config.json query

## Architecture Considerations

### FieldValue Type Architecture

MistKit uses separate types for requests and responses at the OpenAPI schema level to accurately model CloudKit's asymmetric API behavior:

**Type Layers:**
1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (Sources/MistKit/FieldValue.swift)
2. **API Request Layer**: `FieldValueRequest` - No type field, CloudKit infers type from value structure
3. **API Response Layer**: `FieldValueResponse` - Optional type field for explicit type information

**Why Separate Request/Response Types?**
- CloudKit API has asymmetric behavior: requests omit type field, responses may include it
- OpenAPI schema accurately models this asymmetry (openapi.yaml:867-920)
- Swift code generation produces type-safe request/response types
- Compiler prevents accidentally using response types in requests
- Cleaner architecture without nil type values in conversion code

**Generated Types:**
- `Components.Schemas.FieldValueRequest` - Used for modify, create, filter operations
- `Components.Schemas.FieldValueResponse` - Used for query, lookup, changes responses
- `Components.Schemas.RecordRequest` - Records in request bodies
- `Components.Schemas.RecordResponse` - Records in response bodies

**Conversion:**
- Request conversion: `Extensions/OpenAPI/Components+FieldValue.swift` converts domain `FieldValue` → `FieldValueRequest`
- Response conversion: `Service/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue`

### Modern Swift Features to Utilize
- Swift Concurrency (async/await) for all network operations
- Structured concurrency with TaskGroup for parallel operations
Expand Down Expand Up @@ -154,6 +180,47 @@ MistKitLogger.logDebug(_:logger:shouldRedact:) // Debug level
- Set `MISTKIT_DISABLE_LOG_REDACTION=1` to disable redaction for debugging
- Tokens, keys, and secrets are automatically masked in logged messages

### Asset Upload Transport Design

**⚠️ CRITICAL WARNING: Transport Separation**

When providing a custom `AssetUploader` implementation:
- **NEVER** use the CloudKit API transport (`ClientTransport`) for asset uploads
- **MUST** use a separate URLSession instance, NOT shared with api.apple-cloudkit.com
- **MUST NOT** share HTTP/2 connections between CloudKit API and CDN hosts
- Custom uploaders should **ONLY** be used for testing or specialized CDN configurations
- Production code should use the default implementation (`URLSession.shared`)

**Why URLSession instead of ClientTransport?**

Asset uploads use `URLSession.shared` directly rather than the injected `ClientTransport` to avoid HTTP/2 connection reuse issues:

1. **Problem:** CloudKit API (api.apple-cloudkit.com) and CDN (cvws.icloud-content.com) are different hosts
2. **HTTP/2 Issue:** Reusing the same HTTP/2 connection for both hosts causes 421 Misdirected Request errors
3. **Solution:** Use separate URLSession for CDN uploads, maintaining distinct connection pools

**Design:**
- `AssetUploader` closure type allows dependency injection for testing
- Default implementation uses `URLSession.shared.upload(_:to:)` with separate connection pool
- Tests provide mock uploader closures without network calls
- Platform-specific: WASI compilation excludes URLSession code via `#if !os(WASI)`
- **CRITICAL:** Custom uploaders must maintain connection pool separation from CloudKit API

**Implementation Details:**
- AssetUploader type: `(Data, URL) async throws -> (statusCode: Int?, data: Data)`
- Defined in: `Sources/MistKit/Core/AssetUploader.swift`
- URLSession extension: `Sources/MistKit/Extensions/URLSession+AssetUpload.swift`
- Upload orchestration: `Sources/MistKit/Service/CloudKitService+WriteOperations.swift`
- `uploadAssets()` - Complete two-step upload workflow
- `requestAssetUploadURL()` - Step 1: Get CDN upload URL
- `uploadAssetData()` - Step 2: Upload binary data to CDN

**Future Consideration:**
A `ClientTransport` extension could provide a generic upload method, but would need to:
- Handle connection pooling separately for different hosts
- Provide platform-specific implementations (URLSession, custom transports)
- Maintain the same testability via dependency injection

### CloudKit Web Services Integration
- Base URL: `https://api.apple-cloudkit.com`
- Authentication: API Token + Web Auth Token or Server-to-Server Key Authentication
Expand All @@ -171,6 +238,18 @@ MistKitLogger.logDebug(_:logger:shouldRedact:) // Debug level
- Parameterized tests for testing multiple scenarios
- See `testing-enablinganddisabling.md` for Swift Testing patterns

### Asset Upload Testing

**Integration Test Requirements:**
- Verify connection pool separation between CloudKit API and CDN
- Test HTTP/2 connection reuse prevention
- Validate 421 Misdirected Request error handling
- Mock uploaders should simulate realistic HTTP responses

**Test Files:**
- `Tests/MistKitTests/Service/CloudKitServiceUploadTests+*.swift`
- `Tests/MistKitTests/Service/AssetUploadTokenTests.swift`

## Important Implementation Notes

1. **Async/Await First**: All network operations should use async/await, not completion handlers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
return (response, data)
}

Expand Down Expand Up @@ -198,7 +198,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddyUnsignedResponse.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddyUnsignedResponse.utf8)
return (response, data)
}

Expand Down Expand Up @@ -287,7 +287,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
return (response, data)
}

Expand Down Expand Up @@ -339,7 +339,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddyBuildMismatchResponse.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddyBuildMismatchResponse.utf8)
return (response, data)
}

Expand Down Expand Up @@ -483,7 +483,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let invalidJSON = "{ invalid json }".data(using: .utf8)!
let invalidJSON = Data("{ invalid json }".utf8)
return (response, invalidJSON)
}

Expand Down Expand Up @@ -517,7 +517,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
return (response, data)
}

Expand Down Expand Up @@ -547,7 +547,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
return (response, data)
}

Expand Down Expand Up @@ -641,7 +641,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddySignedResponse.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddySignedResponse.utf8)
return (response, data)
}

Expand Down Expand Up @@ -685,7 +685,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddyUnsignedResponse.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddyUnsignedResponse.utf8)
return (response, data)
}

Expand Down Expand Up @@ -731,7 +731,7 @@ internal struct VirtualBuddyFetcherTests {
httpVersion: nil,
headerFields: nil
)!
let data = TestFixtures.virtualBuddySonoma1421Response.data(using: .utf8)!
let data = Data(TestFixtures.virtualBuddySonoma1421Response.utf8)
return (response, data)
}

Expand Down
6 changes: 3 additions & 3 deletions Examples/MistDemo/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 7 additions & 33 deletions Examples/MistDemo/Sources/MistDemo/Commands/CreateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
int64 Integer numbers
double Decimal numbers
timestamp Dates (ISO 8601 or Unix timestamp)
asset Asset URL (from upload-asset command)

EXAMPLES:

Expand Down Expand Up @@ -93,10 +94,13 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
6. Table output format:
mistdemo create --field "title:string:Test" --output-format table

7. With asset (after upload-asset):
mistdemo create --field "title:string:My Photo, image:asset:https://cws.icloud-content.com:443/..."

NOTES:
• Record name is auto-generated if not provided
• JSON files auto-detect field types from values
• Use environment variables CLOUDKIT_API_TOKEN and CLOUDKIT_WEBAUTH_TOKEN
• Use environment variables CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN
to avoid repeating tokens
"""

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

// Convert fields to CloudKit format
let cloudKitFields = try convertFieldsToCloudKit(config.fields)
let cloudKitFields = try config.fields.toCloudKitFields()

// Create the record
// NOTE: Zone support requires enhancements to CloudKitService.createRecord method
let recordInfo = try await client.createRecord(
Expand All @@ -140,36 +144,6 @@ public struct CreateCommand: MistDemoCommand, OutputFormatting {
let randomSuffix = String(Int.random(in: MistDemoConstants.Limits.randomSuffixMin...MistDemoConstants.Limits.randomSuffixMax))
return "\(config.recordType.lowercased())-\(timestamp)-\(randomSuffix)"
}

/// Convert Field array to CloudKit fields dictionary
private func convertFieldsToCloudKit(_ fields: [Field]) throws -> [String: FieldValue] {
var cloudKitFields: [String: FieldValue] = [:]

for field in fields {
do {
let convertedValue = try field.type.convertValue(field.value)
let fieldValue = try convertToFieldValue(convertedValue, type: field.type)
cloudKitFields[field.name] = fieldValue
} catch {
throw CreateError.fieldConversionError(field.name, field.type, field.value, error.localizedDescription)
}
}

return cloudKitFields
}

/// Convert a value to the appropriate FieldValue enum case using the FieldValue extension
private func convertToFieldValue(_ value: Any, type: FieldType) throws -> FieldValue {
guard let fieldValue = FieldValue(value: value, fieldType: type) else {
throw CreateError.fieldConversionError(
"",
type,
String(describing: value),
"Unable to convert value to FieldValue"
)
}
return fieldValue
}
}

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