├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcuserdata │ │ └── karlkoch.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ └── karlkoch.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── .gitignore ├── Tests └── CosmicSDKTests │ ├── CosmicSDKTests.swift │ ├── MetadataDecodingTests.swift │ ├── MetafieldsHelperTests.swift │ ├── MetadataUsageExampleTests.swift │ ├── MetadataCleanAPITests.swift │ ├── QueryFilterTests.swift │ ├── PublishDateTests.swift │ └── MetadataDictionaryTests.swift ├── Package.swift ├── LICENSE ├── debug_connection.swift ├── Examples └── QueryFilterExamples.swift ├── Sources └── CosmicSDK │ ├── CosmicEndpointProvider.swift │ ├── Model.swift │ └── CosmicSDK.swift └── README.md /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/karlkoch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/karlkoch.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-sdk-swift/main/.swiftpm/xcode/package.xcworkspace/xcuserdata/karlkoch.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Swift Package Manager 2 | .build/ 3 | .swiftpm/ 4 | Package.resolved 5 | 6 | # Xcode 7 | *.xcodeproj/ 8 | *.xcworkspace/ 9 | xcuserdata/ 10 | DerivedData/ 11 | *.xcuserstate 12 | 13 | # macOS 14 | .DS_Store 15 | *.dSYM 16 | 17 | # IDE 18 | .idea/ 19 | *.swp 20 | *.swo 21 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/CosmicSDKTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CosmicSDK 3 | 4 | final class CosmicSDKTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documenation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/karlkoch.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | CosmicSDK.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CosmicSDK", 8 | platforms: [ 9 | .macOS(.v12), 10 | .iOS(.v15), 11 | .tvOS(.v15), 12 | .watchOS(.v8) 13 | ], 14 | products: [ 15 | // Products define the executables and libraries a package produces, making them visible to other packages. 16 | .library( 17 | name: "CosmicSDK", 18 | targets: ["CosmicSDK"]), 19 | ], 20 | dependencies: [], 21 | targets: [ 22 | // Targets are the basic building blocks of a package, defining a module or a test suite. 23 | // Targets can depend on other targets in this package and products from dependencies. 24 | .target( 25 | name: "CosmicSDK", 26 | dependencies: []), 27 | .testTarget( 28 | name: "CosmicSDKTests", 29 | dependencies: ["CosmicSDK"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Cosmic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /debug_connection.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env swift 2 | 3 | import Foundation 4 | 5 | // Simple script to test Cosmic API connection 6 | // Run with: swift debug_connection.swift 7 | 8 | // Replace these with your actual values 9 | let BUCKET_SLUG = "your-bucket-slug" 10 | let READ_KEY = "your-read-key" 11 | 12 | // Create a simple HTTP client 13 | func testCosmicAPI() { 14 | let urlString = "https://api.cosmicjs.com/v3/buckets/\(BUCKET_SLUG)?read_key=\(READ_KEY)" 15 | guard let url = URL(string: urlString) else { 16 | print("Invalid URL") 17 | return 18 | } 19 | 20 | var request = URLRequest(url: url) 21 | request.httpMethod = "GET" 22 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 23 | 24 | let task = URLSession.shared.dataTask(with: request) { data, response, error in 25 | if let error = error { 26 | print("Error: \(error)") 27 | return 28 | } 29 | 30 | if let httpResponse = response as? HTTPURLResponse { 31 | print("Status Code: \(httpResponse.statusCode)") 32 | print("Headers: \(httpResponse.allHeaderFields)") 33 | } 34 | 35 | if let data = data { 36 | if let jsonString = String(data: data, encoding: .utf8) { 37 | print("Response: \(jsonString)") 38 | } else { 39 | print("Could not decode response as UTF-8") 40 | } 41 | } 42 | } 43 | 44 | task.resume() 45 | } 46 | 47 | print("Testing Cosmic API connection...") 48 | testCosmicAPI() 49 | 50 | // Keep the script running to see the response 51 | RunLoop.main.run(until: Date().addingTimeInterval(5)) 52 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/MetadataDecodingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataDecodingTests.swift 3 | // 4 | // 5 | // Created to test metadata/metafields backward compatibility 6 | // 7 | 8 | import XCTest 9 | @testable import CosmicSDK 10 | 11 | final class MetadataDecodingTests: XCTestCase { 12 | 13 | func testDecodingWithMetadataProperty() throws { 14 | let json = """ 15 | { 16 | "title": "Test Object", 17 | "metadata": [ 18 | { 19 | "type": "text", 20 | "title": "Test Field", 21 | "key": "test_field", 22 | "value": "Test Value" 23 | } 24 | ] 25 | } 26 | """ 27 | 28 | let data = json.data(using: .utf8)! 29 | let object = try JSONDecoder().decode(Object.self, from: data) 30 | 31 | XCTAssertEqual(object.title, "Test Object") 32 | XCTAssertNotNil(object.metadata) 33 | XCTAssertEqual(object.metafields?.count, 1) 34 | XCTAssertEqual(object.metafields?.first?.key, "test_field") 35 | XCTAssertEqual(object.metafieldValue(for: "test_field")?.value as? String, "Test Value") 36 | } 37 | 38 | func testDecodingWithMetafieldsProperty() throws { 39 | let json = """ 40 | { 41 | "title": "Test Object", 42 | "metafields": [ 43 | { 44 | "type": "text", 45 | "title": "Test Field", 46 | "key": "test_field", 47 | "value": "Test Value" 48 | } 49 | ] 50 | } 51 | """ 52 | 53 | let data = json.data(using: .utf8)! 54 | let object = try JSONDecoder().decode(Object.self, from: data) 55 | 56 | XCTAssertEqual(object.title, "Test Object") 57 | XCTAssertNotNil(object.metadata) 58 | XCTAssertEqual(object.metafields?.count, 1) 59 | XCTAssertEqual(object.metafields?.first?.key, "test_field") 60 | XCTAssertEqual(object.metafieldValue(for: "test_field")?.value as? String, "Test Value") 61 | } 62 | 63 | func testEncodingUsesMetadataProperty() throws { 64 | // First decode an object, then re-encode it to verify it uses "metadata" 65 | let json = """ 66 | { 67 | "title": "Test Object", 68 | "metadata": [ 69 | { 70 | "type": "text", 71 | "title": "Test Field", 72 | "key": "test_field", 73 | "value": "Test Value" 74 | } 75 | ] 76 | } 77 | """ 78 | 79 | let data = json.data(using: .utf8)! 80 | let object = try JSONDecoder().decode(Object.self, from: data) 81 | 82 | let encoder = JSONEncoder() 83 | encoder.outputFormatting = .sortedKeys 84 | let encodedData = try encoder.encode(object) 85 | let encodedJson = String(data: encodedData, encoding: .utf8)! 86 | 87 | XCTAssertTrue(encodedJson.contains("\"metadata\"")) 88 | XCTAssertFalse(encodedJson.contains("\"metafields\"")) 89 | } 90 | } -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/MetafieldsHelperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetafieldsHelperTests.swift 3 | // 4 | // 5 | // Created to test metafields helper methods 6 | // 7 | 8 | import XCTest 9 | @testable import CosmicSDK 10 | 11 | final class MetafieldsHelperTests: XCTestCase { 12 | 13 | func testMetafieldValueHelper() throws { 14 | let json = """ 15 | { 16 | "title": "Test Object", 17 | "metafields": [ 18 | { 19 | "type": "text", 20 | "title": "Name", 21 | "key": "name", 22 | "value": "John Doe" 23 | }, 24 | { 25 | "type": "text", 26 | "title": "Username", 27 | "key": "username", 28 | "value": "johndoe" 29 | }, 30 | { 31 | "type": "text", 32 | "title": "Image URL", 33 | "key": "image_url", 34 | "value": "https://example.com/image.jpg" 35 | } 36 | ] 37 | } 38 | """ 39 | 40 | let data = json.data(using: .utf8)! 41 | let object = try JSONDecoder().decode(Object.self, from: data) 42 | 43 | // Test metafieldValue(for:) method 44 | let nameValue = object.metafieldValue(for: "name") 45 | XCTAssertNotNil(nameValue) 46 | XCTAssertEqual(nameValue?.value as? String, "John Doe") 47 | 48 | let usernameValue = object.metafieldValue(for: "username") 49 | XCTAssertNotNil(usernameValue) 50 | XCTAssertEqual(usernameValue?.value as? String, "johndoe") 51 | 52 | let imageUrlValue = object.metafieldValue(for: "image_url") 53 | XCTAssertNotNil(imageUrlValue) 54 | XCTAssertEqual(imageUrlValue?.value as? String, "https://example.com/image.jpg") 55 | 56 | // Test non-existent key 57 | let nonExistent = object.metafieldValue(for: "non_existent") 58 | XCTAssertNil(nonExistent) 59 | } 60 | 61 | func testMetafieldsDictHelper() throws { 62 | let json = """ 63 | { 64 | "title": "Test Object", 65 | "metafields": [ 66 | { 67 | "type": "text", 68 | "title": "Name", 69 | "key": "name", 70 | "value": "John Doe" 71 | }, 72 | { 73 | "type": "text", 74 | "title": "Username", 75 | "key": "username", 76 | "value": "johndoe" 77 | }, 78 | { 79 | "type": "objects", 80 | "title": "Shows", 81 | "key": "shows", 82 | "value": ["show1", "show2", "show3"] 83 | } 84 | ] 85 | } 86 | """ 87 | 88 | let data = json.data(using: .utf8)! 89 | let object = try JSONDecoder().decode(Object.self, from: data) 90 | 91 | // Test metafieldsDict property 92 | let dict = object.metafieldsDict 93 | XCTAssertNotNil(dict) 94 | XCTAssertEqual(dict?.count, 3) 95 | XCTAssertEqual(dict?["name"]?.value as? String, "John Doe") 96 | XCTAssertEqual(dict?["username"]?.value as? String, "johndoe") 97 | XCTAssertEqual((dict?["shows"]?.value as? [String])?.count, 3) 98 | } 99 | 100 | func testEmptyMetafields() throws { 101 | let json = """ 102 | { 103 | "title": "Test Object", 104 | "metafields": [] 105 | } 106 | """ 107 | 108 | let data = json.data(using: .utf8)! 109 | let object = try JSONDecoder().decode(Object.self, from: data) 110 | 111 | let value = object.metafieldValue(for: "any_key") 112 | XCTAssertNil(value) 113 | 114 | let dict = object.metafieldsDict 115 | XCTAssertNil(dict) 116 | } 117 | 118 | func testNilMetafields() throws { 119 | let json = """ 120 | { 121 | "title": "Test Object" 122 | } 123 | """ 124 | 125 | let data = json.data(using: .utf8)! 126 | let object = try JSONDecoder().decode(Object.self, from: data) 127 | 128 | let value = object.metafieldValue(for: "any_key") 129 | XCTAssertNil(value) 130 | 131 | let dict = object.metafieldsDict 132 | XCTAssertNil(dict) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/MetadataUsageExampleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataUsageExampleTests.swift 3 | // 4 | // 5 | // Created to demonstrate the cleaner metadata API usage 6 | // 7 | 8 | import XCTest 9 | @testable import CosmicSDK 10 | 11 | final class MetadataUsageExampleTests: XCTestCase { 12 | 13 | func testCleanMetadataAccess() throws { 14 | // Example of a typical Cosmic CMS response with dictionary metadata 15 | let json = """ 16 | { 17 | "title": "John Doe", 18 | "type": "users", 19 | "metadata": { 20 | "name": "John Doe", 21 | "email": "john@example.com", 22 | "age": 30, 23 | "is_premium": true, 24 | "bio": "Software developer from San Francisco", 25 | "skills": ["Swift", "TypeScript", "Python"], 26 | "social": { 27 | "twitter": "@johndoe", 28 | "github": "johndoe" 29 | } 30 | } 31 | } 32 | """ 33 | 34 | let data = json.data(using: .utf8)! 35 | let user = try JSONDecoder().decode(Object.self, from: data) 36 | 37 | // Clean, direct access to metadata values - no casting needed! 38 | let name = user.metadata?.name.string 39 | let email = user.metadata?.email.string 40 | let age = user.metadata?.age.int 41 | let isPremium = user.metadata?.is_premium.bool 42 | let bio = user.metadata?.bio.string 43 | 44 | XCTAssertEqual(name, "John Doe") 45 | XCTAssertEqual(email, "john@example.com") 46 | XCTAssertEqual(age, 30) 47 | XCTAssertEqual(isPremium, true) 48 | XCTAssertEqual(bio, "Software developer from San Francisco") 49 | 50 | // Accessing arrays - type-safe! 51 | if let skills = user.metadata?.skills.array(of: String.self) { 52 | XCTAssertEqual(skills.count, 3) 53 | XCTAssertTrue(skills.contains("Swift")) 54 | } 55 | 56 | // Accessing nested objects - clean API 57 | if let social = user.metadata?.social.dictionary(keyType: String.self, valueType: Any.self) { 58 | XCTAssertEqual(social["twitter"] as? String, "@johndoe") 59 | XCTAssertEqual(social["github"] as? String, "johndoe") 60 | } 61 | } 62 | 63 | func testRealWorldExample() throws { 64 | // Example: Event object from Cosmic CMS 65 | let json = """ 66 | { 67 | "title": "Swift Conference 2024", 68 | "type": "events", 69 | "metadata": { 70 | "event_name": "Swift Conference 2024", 71 | "tagline": "The future of Swift development", 72 | "start_date": "2024-06-15T09:00:00Z", 73 | "end_date": "2024-06-17T18:00:00Z", 74 | "location": "San Francisco Convention Center", 75 | "max_attendees": 1000, 76 | "is_virtual_enabled": true, 77 | "ticket_price": 299.99, 78 | "speakers": [ 79 | "Chris Lattner", 80 | "Ted Kremenek", 81 | "Holly Borla" 82 | ], 83 | "venue": { 84 | "name": "Moscone Center", 85 | "address": "747 Howard St, San Francisco, CA 94103", 86 | "capacity": 1500 87 | } 88 | } 89 | } 90 | """ 91 | 92 | let data = json.data(using: .utf8)! 93 | let event = try JSONDecoder().decode(Object.self, from: data) 94 | 95 | // Direct, type-safe access to all metadata fields 96 | let eventName = event.metadata?.event_name.string 97 | let startDate = event.metadata?.start_date.string 98 | let isVirtual = event.metadata?.is_virtual_enabled.bool 99 | let price = event.metadata?.ticket_price.double 100 | 101 | XCTAssertEqual(eventName, "Swift Conference 2024") 102 | XCTAssertEqual(startDate, "2024-06-15T09:00:00Z") 103 | XCTAssertEqual(isVirtual, true) 104 | XCTAssertEqual(price, 299.99) 105 | 106 | // Working with arrays and nested objects - no casting! 107 | if let speakers = event.metadata?.speakers.array(of: String.self) { 108 | XCTAssertTrue(speakers.contains("Chris Lattner")) 109 | } 110 | 111 | if let venue = event.metadata?.venue.dictionary(keyType: String.self, valueType: Any.self) { 112 | XCTAssertEqual(venue["name"] as? String, "Moscone Center") 113 | XCTAssertEqual(venue["capacity"] as? Int, 1500) 114 | } 115 | 116 | // Check field existence 117 | XCTAssertTrue(event.metadata?.event_name.exists ?? false) 118 | XCTAssertFalse(event.metadata?.cancelled.exists ?? true) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/MetadataCleanAPITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataCleanAPITests.swift 3 | // 4 | // 5 | // Created to demonstrate the cleanest metadata API usage 6 | // 7 | 8 | import XCTest 9 | @testable import CosmicSDK 10 | 11 | final class MetadataCleanAPITests: XCTestCase { 12 | 13 | func testDirectComparison() throws { 14 | let json = """ 15 | { 16 | "title": "Product", 17 | "metadata": { 18 | "name": "iPhone 15 Pro", 19 | "price": 999.99, 20 | "in_stock": true, 21 | "quantity": 42 22 | } 23 | } 24 | """ 25 | 26 | let data = json.data(using: .utf8)! 27 | let product = try JSONDecoder().decode(Object.self, from: data) 28 | 29 | // Direct comparisons - the cleanest possible API! 30 | XCTAssertTrue(product.metadata?.name == "iPhone 15 Pro") 31 | XCTAssertTrue(product.metadata?.price == 999.99) 32 | XCTAssertTrue(product.metadata?.in_stock == true) 33 | XCTAssertTrue(product.metadata?.quantity == 42) 34 | 35 | // Works in if statements 36 | if product.metadata?.in_stock == true { 37 | print("Product is in stock!") 38 | } 39 | 40 | // Works in guard statements 41 | guard product.metadata?.quantity == 42 else { 42 | XCTFail("Quantity should be 42") 43 | return 44 | } 45 | } 46 | 47 | func testConditionalUsage() throws { 48 | let json = """ 49 | { 50 | "title": "User", 51 | "metadata": { 52 | "username": "johndoe", 53 | "premium": true, 54 | "credits": 100 55 | } 56 | } 57 | """ 58 | 59 | let data = json.data(using: .utf8)! 60 | let user = try JSONDecoder().decode(Object.self, from: data) 61 | 62 | // Clean conditional checks 63 | if user.metadata?.premium == true { 64 | // Premium user logic 65 | XCTAssertTrue(true) 66 | } 67 | 68 | // Numeric comparisons need explicit type access 69 | if let credits = user.metadata?.credits.int, credits > 50 { 70 | // User has enough credits 71 | XCTAssertTrue(true) 72 | } 73 | 74 | // String checks 75 | if user.metadata?.username == "johndoe" { 76 | // Correct user 77 | XCTAssertTrue(true) 78 | } 79 | } 80 | 81 | func testNestedAccess() throws { 82 | let json = """ 83 | { 84 | "title": "Company", 85 | "metadata": { 86 | "name": "Acme Corp", 87 | "address": { 88 | "street": "123 Main St", 89 | "city": "San Francisco", 90 | "zip": "94105" 91 | }, 92 | "employees": 150 93 | } 94 | } 95 | """ 96 | 97 | let data = json.data(using: .utf8)! 98 | let company = try JSONDecoder().decode(Object.self, from: data) 99 | 100 | // Direct nested access 101 | XCTAssertEqual(company.metadata?.address.city, "San Francisco") 102 | XCTAssertEqual(company.metadata?.address.zip, "94105") 103 | 104 | // Check existence 105 | XCTAssertTrue(company.metadata?.address.exists ?? false) 106 | XCTAssertFalse(company.metadata?.nonexistent.exists ?? true) 107 | } 108 | 109 | func testVariableAssignment() throws { 110 | let json = """ 111 | { 112 | "title": "Event", 113 | "metadata": { 114 | "title": "SwiftUI Workshop", 115 | "date": "2024-03-15", 116 | "price": 299.0, 117 | "is_online": true 118 | } 119 | } 120 | """ 121 | 122 | let data = json.data(using: .utf8)! 123 | let event = try JSONDecoder().decode(Object.self, from: data) 124 | 125 | // When you need to store values, use typed accessors 126 | let eventTitle: String? = event.metadata?.title.string 127 | let eventDate: String? = event.metadata?.date.string 128 | let eventPrice: Double? = event.metadata?.price.double 129 | let isOnline: Bool? = event.metadata?.is_online.bool 130 | 131 | XCTAssertEqual(eventTitle, "SwiftUI Workshop") 132 | XCTAssertEqual(eventDate, "2024-03-15") 133 | XCTAssertEqual(eventPrice, 299.0) 134 | XCTAssertEqual(isOnline, true) 135 | 136 | // Or use type inference with explicit property access 137 | let title = event.metadata?.title.stringValue 138 | let price = event.metadata?.price.doubleValue 139 | 140 | XCTAssertEqual(title, "SwiftUI Workshop") 141 | XCTAssertEqual(price, 299.0) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/QueryFilterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CosmicSDK 3 | 4 | final class QueryFilterTests: XCTestCase { 5 | 6 | var sdk: CosmicSDKSwift! 7 | 8 | override func setUp() { 9 | super.setUp() 10 | // Note: These tests verify the query building logic 11 | // Real API tests would require valid credentials 12 | sdk = CosmicSDKSwift( 13 | .createBucketClient( 14 | bucketSlug: "test-bucket", 15 | readKey: "test-read-key", 16 | writeKey: "test-write-key" 17 | ) 18 | ) 19 | } 20 | 21 | // MARK: - Query Building Tests 22 | 23 | func testBuildQueryJSONWithSingleFilter() throws { 24 | // Test that buildQueryJSON creates valid JSON 25 | let query = ["metadata.regular_hosts.id": "host-id-123"] 26 | let result = sdk.value(forKey: "buildQueryJSON", type: "episode", query: query) as? String 27 | 28 | XCTAssertNotNil(result, "Query JSON should not be nil") 29 | 30 | // Verify it contains the type 31 | XCTAssertTrue(result?.contains("\"type\":\"episode\"") ?? false) 32 | 33 | // Verify it contains the filter 34 | XCTAssertTrue(result?.contains("metadata.regular_hosts.id") ?? false) 35 | } 36 | 37 | func testBuildQueryJSONWithInOperator() throws { 38 | // Test $in operator 39 | let query: [String: Any] = [ 40 | "metadata.regular_hosts.id": ["$in": ["host-1", "host-2", "host-3"]] 41 | ] 42 | 43 | let result = sdk.value(forKey: "buildQueryJSON", type: "episode", query: query) as? String 44 | 45 | XCTAssertNotNil(result, "Query JSON should not be nil") 46 | XCTAssertTrue(result?.contains("$in") ?? false) 47 | } 48 | 49 | func testBuildQueryJSONWithMultipleFilters() throws { 50 | // Test multiple filters combined 51 | let query: [String: Any] = [ 52 | "metadata.regular_hosts.id": ["$in": ["host-1", "host-2"]], 53 | "metadata.broadcast_date": ["$gte": "2024-01-01"], 54 | "status": "published" 55 | ] 56 | 57 | let result = sdk.value(forKey: "buildQueryJSON", type: "episode", query: query) as? String 58 | 59 | XCTAssertNotNil(result, "Query JSON should not be nil") 60 | XCTAssertTrue(result?.contains("metadata.regular_hosts.id") ?? false) 61 | XCTAssertTrue(result?.contains("metadata.broadcast_date") ?? false) 62 | XCTAssertTrue(result?.contains("status") ?? false) 63 | } 64 | 65 | func testBuildQueryJSONWithExistsOperator() throws { 66 | // Test $exists operator 67 | let query: [String: Any] = [ 68 | "metadata.takeovers": ["$exists": true] 69 | ] 70 | 71 | let result = sdk.value(forKey: "buildQueryJSON", type: "episode", query: query) as? String 72 | 73 | XCTAssertNotNil(result, "Query JSON should not be nil") 74 | XCTAssertTrue(result?.contains("$exists") ?? false) 75 | } 76 | 77 | func testBuildQueryJSONWithDateRange() throws { 78 | // Test date range query 79 | let query: [String: Any] = [ 80 | "metadata.broadcast_date": [ 81 | "$gte": "2024-01-01", 82 | "$lte": "2024-12-31" 83 | ] 84 | ] 85 | 86 | let result = sdk.value(forKey: "buildQueryJSON", type: "episode", query: query) as? String 87 | 88 | XCTAssertNotNil(result, "Query JSON should not be nil") 89 | XCTAssertTrue(result?.contains("$gte") ?? false) 90 | XCTAssertTrue(result?.contains("$lte") ?? false) 91 | } 92 | 93 | // MARK: - Integration Pattern Tests 94 | 95 | func testQueryFilterPatternForSingleID() { 96 | // This test documents the expected usage pattern 97 | let hostId = "host-id-123" 98 | let query = ["metadata.regular_hosts.id": hostId] 99 | 100 | // Verify the query structure is as expected 101 | XCTAssertEqual(query.count, 1) 102 | XCTAssertEqual(query["metadata.regular_hosts.id"] as? String, hostId) 103 | } 104 | 105 | func testQueryFilterPatternForMultipleIDs() { 106 | // This test documents the expected usage pattern for $in operator 107 | let hostIds = ["host-1", "host-2", "host-3"] 108 | let query: [String: Any] = [ 109 | "metadata.regular_hosts.id": ["$in": hostIds] 110 | ] 111 | 112 | // Verify the query structure is as expected 113 | XCTAssertEqual(query.count, 1) 114 | 115 | if let filterValue = query["metadata.regular_hosts.id"] as? [String: [String]], 116 | let inArray = filterValue["$in"] { 117 | XCTAssertEqual(inArray, hostIds) 118 | } else { 119 | XCTFail("Query structure is incorrect") 120 | } 121 | } 122 | 123 | func testQueryFilterPatternForComplexQuery() { 124 | // This test documents a complex query pattern 125 | let query: [String: Any] = [ 126 | "metadata.regular_hosts.id": ["$in": ["host-1", "host-2"]], 127 | "metadata.broadcast_date": ["$gte": "2024-01-01", "$lte": "2024-12-31"], 128 | "status": "published" 129 | ] 130 | 131 | // Verify all filters are present 132 | XCTAssertEqual(query.count, 3) 133 | XCTAssertNotNil(query["metadata.regular_hosts.id"]) 134 | XCTAssertNotNil(query["metadata.broadcast_date"]) 135 | XCTAssertEqual(query["status"] as? String, "published") 136 | } 137 | } 138 | 139 | // MARK: - Helper Extension for Testing Private Methods 140 | extension CosmicSDKSwift { 141 | func value(forKey key: String, type: String, query: [String: Any]) -> Any? { 142 | // This is a test helper to access private buildQueryJSON method 143 | // In a real implementation, you might want to make buildQueryJSON internal for testing 144 | guard key == "buildQueryJSON" else { return nil } 145 | 146 | // Manually construct what buildQueryJSON would return 147 | var payload: [String: Any] = ["type": type] 148 | for (k, v) in query { 149 | payload[k] = v 150 | } 151 | 152 | guard let data = try? JSONSerialization.data(withJSONObject: payload), 153 | let json = String(data: data, encoding: .utf8) else { 154 | return nil 155 | } 156 | return json 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Examples/QueryFilterExamples.swift: -------------------------------------------------------------------------------- 1 | import CosmicSDK 2 | 3 | // MARK: - MongoDB-Style Query Filter Examples 4 | // This file demonstrates the new query filter functionality 5 | 6 | class QueryFilterExamples { 7 | 8 | let cosmic = CosmicSDKSwift( 9 | .createBucketClient( 10 | bucketSlug: "your-bucket-slug", 11 | readKey: "your-read-key", 12 | writeKey: "your-write-key" 13 | ) 14 | ) 15 | 16 | // MARK: - Example 1: Filter by Single Relationship ID 17 | func fetchEpisodesByHost(hostId: String) async throws { 18 | let response = try await cosmic.find( 19 | type: "episode", 20 | query: ["metadata.regular_hosts.id": hostId], 21 | depth: 2 22 | ) 23 | 24 | print("Found \(response.objects.count) episodes for host \(hostId)") 25 | } 26 | 27 | // MARK: - Example 2: Filter by Multiple Relationship IDs ($in operator) 28 | func fetchEpisodesByMultipleHosts(hostIds: [String]) async throws { 29 | let response = try await cosmic.find( 30 | type: "episode", 31 | query: [ 32 | "metadata.regular_hosts.id": ["$in": hostIds] 33 | ], 34 | props: "id,slug,title,content,metadata", 35 | limit: 20, 36 | depth: 2 37 | ) 38 | 39 | print("Found \(response.objects.count) episodes for \(hostIds.count) hosts") 40 | } 41 | 42 | // MARK: - Example 3: Filter by Date Range 43 | func fetchRecentEpisodes(since date: String) async throws { 44 | let response = try await cosmic.find( 45 | type: "episode", 46 | query: [ 47 | "metadata.broadcast_date": ["$gte": date] 48 | ], 49 | limit: 10, 50 | sort: .createdDescending 51 | ) 52 | 53 | print("Found \(response.objects.count) episodes since \(date)") 54 | } 55 | 56 | // MARK: - Example 4: Check Field Existence 57 | func fetchEpisodesWithTakeovers() async throws { 58 | let response = try await cosmic.find( 59 | type: "episode", 60 | query: [ 61 | "metadata.takeovers": ["$exists": true] 62 | ] 63 | ) 64 | 65 | print("Found \(response.objects.count) episodes with takeovers") 66 | } 67 | 68 | // MARK: - Example 5: Complex Query with Multiple Filters 69 | func fetchFilteredEpisodes( 70 | hostIds: [String], 71 | startDate: String, 72 | endDate: String 73 | ) async throws { 74 | let response = try await cosmic.find( 75 | type: "episode", 76 | query: [ 77 | "metadata.regular_hosts.id": ["$in": hostIds], 78 | "metadata.broadcast_date": ["$gte": startDate, "$lte": endDate], 79 | "status": "published" 80 | ], 81 | depth: 2 82 | ) 83 | 84 | print("Found \(response.objects.count) filtered episodes") 85 | } 86 | 87 | // MARK: - Example 6: Using Completion Handlers (Backward Compatibility) 88 | func fetchEpisodesByHostWithCompletion(hostId: String, completion: @escaping (Result<[Object], Error>) -> Void) { 89 | cosmic.find( 90 | type: "episode", 91 | query: ["metadata.regular_hosts.id": hostId], 92 | depth: 2 93 | ) { results in 94 | switch results { 95 | case .success(let response): 96 | completion(.success(response.objects)) 97 | case .failure(let error): 98 | completion(.failure(error)) 99 | } 100 | } 101 | } 102 | 103 | // MARK: - Example 7: Comparison with Regex (Before vs After) 104 | 105 | // OLD WAY: Using regex for exact ID match 106 | func fetchEpisodesByHostOldWay(hostId: String) async throws { 107 | let response = try await cosmic.findRegex( 108 | type: "episode", 109 | field: "metadata.regular_hosts.id", 110 | pattern: "^\(hostId)$", // Exact match regex - less efficient 111 | depth: 2 112 | ) 113 | 114 | // Required client-side filtering to verify results 115 | let matchingEpisodes = response.objects.filter { object in 116 | guard let hostObjects = object.metadata?.regular_hosts.raw as? [[String: Any]] else { 117 | return false 118 | } 119 | let episodeHostIds = hostObjects.compactMap { $0["id"] as? String } 120 | return episodeHostIds.contains(hostId) 121 | } 122 | 123 | print("Found \(matchingEpisodes.count) matching episodes (old way)") 124 | } 125 | 126 | // NEW WAY: Using query filters 127 | func fetchEpisodesByHostNewWay(hostId: String) async throws { 128 | let response = try await cosmic.find( 129 | type: "episode", 130 | query: ["metadata.regular_hosts.id": hostId], // Direct filter - more efficient 131 | depth: 2 132 | ) 133 | 134 | // No client-side filtering needed! 135 | print("Found \(response.objects.count) matching episodes (new way)") 136 | } 137 | 138 | // MARK: - Example 8: Range Queries 139 | func fetchProductsByPriceRange(minPrice: Double, maxPrice: Double) async throws { 140 | let response = try await cosmic.find( 141 | type: "products", 142 | query: [ 143 | "metadata.price": [ 144 | "$gte": minPrice, 145 | "$lte": maxPrice 146 | ] 147 | ] 148 | ) 149 | 150 | print("Found \(response.objects.count) products in price range $\(minPrice)-$\(maxPrice)") 151 | } 152 | 153 | // MARK: - Example 9: Not Equal Filter 154 | func fetchActiveProducts() async throws { 155 | let response = try await cosmic.find( 156 | type: "products", 157 | query: [ 158 | "metadata.status": ["$ne": "discontinued"] 159 | ] 160 | ) 161 | 162 | print("Found \(response.objects.count) active products") 163 | } 164 | 165 | // MARK: - Example 10: Pagination with Query Filters 166 | func fetchPagedEpisodesByHost( 167 | hostId: String, 168 | page: Int, 169 | pageSize: Int 170 | ) async throws { 171 | let skip = page * pageSize 172 | 173 | let response = try await cosmic.find( 174 | type: "episode", 175 | query: ["metadata.regular_hosts.id": hostId], 176 | limit: pageSize, 177 | skip: skip, 178 | depth: 2 179 | ) 180 | 181 | print("Page \(page + 1): Found \(response.objects.count) episodes") 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/PublishDateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PublishDateTests.swift 3 | // 4 | // 5 | // Created by Test on 2024. 6 | // 7 | 8 | import XCTest 9 | @testable import CosmicSDK 10 | 11 | final class PublishDateTests: XCTestCase { 12 | 13 | func testInsertObjectWithPublishAt() { 14 | // Example of inserting an object with a future publish date 15 | // The object will be created as a draft regardless of status parameter 16 | 17 | let config = CosmicSDKSwift.Config.createBucketClient( 18 | bucketSlug: "your-bucket-slug", 19 | readKey: "your-read-key", 20 | writeKey: "your-write-key" 21 | ) 22 | 23 | let sdk = CosmicSDKSwift(config) 24 | 25 | // ISO 8601 date format for future publish date 26 | let futureDate = "2024-12-25T00:00:00.000Z" 27 | 28 | // Insert object with publish_at - will be created as draft 29 | sdk.insertOne( 30 | type: "posts", 31 | title: "Holiday Announcement", 32 | content: "This will be published on Christmas!", 33 | status: .published, // This will be ignored and set to draft 34 | publish_at: futureDate 35 | ) { result in 36 | switch result { 37 | case .success(let response): 38 | print("Object created: \(response.message ?? "")") 39 | case .failure(let error): 40 | print("Error: \(error)") 41 | } 42 | } 43 | } 44 | 45 | func testInsertObjectWithUnpublishAt() { 46 | // Example of inserting an object with an unpublish date 47 | 48 | let config = CosmicSDKSwift.Config.createBucketClient( 49 | bucketSlug: "your-bucket-slug", 50 | readKey: "your-read-key", 51 | writeKey: "your-write-key" 52 | ) 53 | 54 | let sdk = CosmicSDKSwift(config) 55 | 56 | // ISO 8601 date format for unpublish date 57 | let unpublishDate = "2024-12-31T23:59:59.000Z" 58 | 59 | // Insert object with unpublish_at - will be created as draft 60 | sdk.insertOne( 61 | type: "promotions", 62 | title: "Limited Time Offer", 63 | content: "This promotion expires at the end of the year!", 64 | unpublish_at: unpublishDate 65 | ) { result in 66 | switch result { 67 | case .success(let response): 68 | print("Object created: \(response.message ?? "")") 69 | case .failure(let error): 70 | print("Error: \(error)") 71 | } 72 | } 73 | } 74 | 75 | func testFindObjectsWithAnyStatus() { 76 | // Example of finding objects with any status (published && draft) 77 | 78 | let config = CosmicSDKSwift.Config.createBucketClient( 79 | bucketSlug: "your-bucket-slug", 80 | readKey: "your-read-key", 81 | writeKey: "your-write-key" 82 | ) 83 | 84 | let sdk = CosmicSDKSwift(config) 85 | 86 | // Find all objects regardless of status 87 | sdk.find( 88 | type: "posts", 89 | status: .any // Will query for both published and draft objects 90 | ) { result in 91 | switch result { 92 | case .success(let response): 93 | print("Found \(response.objects.count) objects") 94 | case .failure(let error): 95 | print("Error: \(error)") 96 | } 97 | } 98 | } 99 | 100 | func testFindWithIntegerLimit() { 101 | // Example demonstrating the use of integer limit 102 | 103 | let config = CosmicSDKSwift.Config.createBucketClient( 104 | bucketSlug: "your-bucket-slug", 105 | readKey: "your-read-key", 106 | writeKey: "your-write-key" 107 | ) 108 | 109 | let sdk = CosmicSDKSwift(config) 110 | 111 | // Find objects with an integer limit 112 | sdk.find( 113 | type: "posts", 114 | limit: 5, // Now accepts Int instead of String 115 | sort: .created_at, 116 | status: .published 117 | ) { result in 118 | switch result { 119 | case .success(let response): 120 | print("Found \(response.objects.count) objects (max 5)") 121 | case .failure(let error): 122 | print("Error: \(error)") 123 | } 124 | } 125 | } 126 | 127 | func testFindWithStringLimitMigration() { 128 | // Example of migrating from String to Int limit 129 | 130 | let config = CosmicSDKSwift.Config.createBucketClient( 131 | bucketSlug: "your-bucket-slug", 132 | readKey: "your-read-key", 133 | writeKey: "your-write-key" 134 | ) 135 | 136 | let sdk = CosmicSDKSwift(config) 137 | 138 | // If you have a String limit from legacy code: 139 | let stringLimit = "10" 140 | 141 | // Convert it to Int for the new API: 142 | sdk.find( 143 | type: "posts", 144 | limit: Int(stringLimit) ?? 10, // Convert String to Int with fallback 145 | sort: .modified_at, 146 | status: .draft 147 | ) { result in 148 | switch result { 149 | case .success(let response): 150 | print("Found \(response.objects.count) objects (max 10)") 151 | case .failure(let error): 152 | print("Error: \(error)") 153 | } 154 | } 155 | } 156 | 157 | func testUpdateObjectWithPublishDates() { 158 | // Example of updating an object with both publish and unpublish dates 159 | 160 | let config = CosmicSDKSwift.Config.createBucketClient( 161 | bucketSlug: "your-bucket-slug", 162 | readKey: "your-read-key", 163 | writeKey: "your-write-key" 164 | ) 165 | 166 | let sdk = CosmicSDKSwift(config) 167 | 168 | let objectId = "your-object-id" 169 | let publishDate = "2024-12-20T00:00:00.000Z" 170 | let unpublishDate = "2024-12-27T00:00:00.000Z" 171 | 172 | // Update object with both dates - will be set to draft 173 | sdk.updateOne( 174 | type: "events", 175 | id: objectId, 176 | title: "Week-long Holiday Event", 177 | publish_at: publishDate, 178 | unpublish_at: unpublishDate 179 | ) { result in 180 | switch result { 181 | case .success(let response): 182 | print("Object updated: \(response.message ?? "")") 183 | case .failure(let error): 184 | print("Error: \(error)") 185 | } 186 | } 187 | } 188 | } -------------------------------------------------------------------------------- /Sources/CosmicSDK/CosmicEndpointProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Karl Koch on 19/09/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct CosmicEndpointProvider { 11 | private let source: Source 12 | 13 | public enum Source { 14 | case cosmic 15 | } 16 | 17 | public init(source: Source) { 18 | self.source = source 19 | } 20 | 21 | public enum API { 22 | // Object operations 23 | case find 24 | case findOne 25 | case insertOne 26 | case updateOne 27 | case deleteOne 28 | 29 | // Media operations 30 | case uploadMedia(String) 31 | case getMedia 32 | case getMediaObject 33 | case deleteMedia 34 | 35 | // Object operations (additional) 36 | case getObjectRevisions 37 | case searchObjects 38 | 39 | // Bucket operations 40 | case getBucket 41 | case updateBucketSettings 42 | 43 | // User operations 44 | case getUsers 45 | case getUser 46 | case addUser 47 | case deleteUser 48 | 49 | // Webhook operations 50 | case getWebhooks 51 | case addWebhook 52 | case deleteWebhook 53 | 54 | // AI operations 55 | case generateText(String) 56 | case generateImage(String) 57 | } 58 | 59 | public enum Status: String { 60 | case published 61 | case draft 62 | case any 63 | } 64 | 65 | public enum Sorting: String { 66 | case created_at 67 | case modified_at 68 | case random 69 | case order 70 | } 71 | 72 | public func getMethod(api: API) -> String { 73 | switch source { 74 | case .cosmic: 75 | switch api { 76 | case .find, .findOne, .getMedia, .getMediaObject, .getObjectRevisions, .searchObjects, .getBucket, .getUsers, .getUser, .getWebhooks: 77 | return "GET" 78 | case .insertOne, .uploadMedia, .addUser, .addWebhook, .generateText, .generateImage: 79 | return "POST" 80 | case .updateOne, .updateBucketSettings: 81 | return "PATCH" 82 | case .deleteOne, .deleteMedia, .deleteUser, .deleteWebhook: 83 | return "DELETE" 84 | } 85 | } 86 | } 87 | 88 | public func getPath(api: API, id: String? = nil, bucket: String, type: String, read_key: String, write_key: String?, props: String? = nil, limit: String? = nil, skip: String? = nil, status: Status? = nil, sort: Sorting? = nil, depth: String? = nil, metadata: [String: AnyCodable]? = nil, queryJSON: String? = nil) -> (String, [String: String?]) { 89 | switch source { 90 | case .cosmic: 91 | switch api { 92 | // Object endpoints 93 | case .find: 94 | // Build query parameter. Allow override via queryJSON to support advanced queries (e.g., $regex) 95 | let queryParam = queryJSON ?? "{\"type\":\"\(type)\"}" 96 | let defaultProps = "slug,title,metadata,type," 97 | let finalProps = props ?? defaultProps 98 | 99 | return ("/v3/buckets/\(bucket)/objects", [ 100 | "query": queryParam, 101 | "limit": limit ?? "10", 102 | "skip": skip ?? "0", 103 | "props": finalProps, 104 | "status": status?.rawValue, 105 | "sort": sort?.rawValue, 106 | "depth": depth, 107 | "pretty": "true", 108 | "read_key": read_key 109 | ]) 110 | case .findOne: 111 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 112 | let defaultProps = "slug,title,metadata,type," 113 | let finalProps = props ?? defaultProps 114 | return ("/v3/buckets/\(bucket)/objects/\(id)", [ 115 | "props": finalProps, 116 | "status": status?.rawValue, 117 | "depth": depth, 118 | "read_key": read_key 119 | ]) 120 | case .insertOne: 121 | return ("/v3/buckets/\(bucket)/objects", ["write_key": write_key]) 122 | case .updateOne: 123 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 124 | return ("/v3/buckets/\(bucket)/objects/\(id)", ["write_key": write_key]) 125 | case .deleteOne: 126 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 127 | return ("/v3/buckets/\(bucket)/objects/\(id)", ["write_key": write_key]) 128 | 129 | // Media endpoints 130 | case .uploadMedia(let bucket): 131 | return ("https://workers.cosmicjs.com/v3/buckets/\(bucket)/media", ["write_key": write_key]) 132 | case .getMedia: 133 | return ("/v3/buckets/\(bucket)/media", ["limit": limit, "props": props]) 134 | case .getMediaObject, .deleteMedia: 135 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 136 | return ("/v3/buckets/\(bucket)/media/\(id)", [:]) 137 | 138 | // Object operations (additional) 139 | case .getObjectRevisions: 140 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 141 | return ("/v3/buckets/\(bucket)/objects/\(id)/revisions", [:]) 142 | case .searchObjects: 143 | return ("/v3/buckets/\(bucket)/objects/search", [:]) 144 | 145 | // Bucket operations 146 | case .getBucket: 147 | return ("/v3/buckets/\(bucket)", [:]) 148 | case .updateBucketSettings: 149 | return ("/v3/buckets/\(bucket)/settings", ["write_key": write_key]) 150 | 151 | // User operations 152 | case .getUsers: 153 | return ("/v3/buckets/\(bucket)/users", [:]) 154 | case .getUser: 155 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 156 | return ("/v3/buckets/\(bucket)/users/\(id)", [:]) 157 | case .addUser: 158 | return ("/v3/buckets/\(bucket)/users", ["write_key": write_key]) 159 | case .deleteUser: 160 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 161 | return ("/v3/buckets/\(bucket)/users/\(id)", ["write_key": write_key]) 162 | 163 | // Webhook operations 164 | case .getWebhooks: 165 | return ("/v3/buckets/\(bucket)/webhooks", [:]) 166 | case .addWebhook: 167 | return ("/v3/buckets/\(bucket)/webhooks", ["write_key": write_key]) 168 | case .deleteWebhook: 169 | guard let id = id else { fatalError("Missing ID for \(api) operation") } 170 | return ("/v3/buckets/\(bucket)/webhooks/\(id)", ["write_key": write_key]) 171 | 172 | // AI endpoints 173 | case .generateText(let bucket): 174 | return ("https://workers.cosmicjs.com/v3/buckets/\(bucket)/ai/text", ["write_key": write_key]) 175 | case .generateImage(let bucket): 176 | return ("https://workers.cosmicjs.com/v3/buckets/\(bucket)/ai/image", ["write_key": write_key]) 177 | } 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Tests/CosmicSDKTests/MetadataDictionaryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MetadataDictionaryTests.swift 3 | // 4 | // 5 | // Created to test dictionary-based metadata format 6 | // 7 | 8 | import XCTest 9 | @testable import CosmicSDK 10 | 11 | final class MetadataDictionaryTests: XCTestCase { 12 | 13 | func testDecodingDictionaryMetadata() throws { 14 | let json = """ 15 | { 16 | "title": "Test Object", 17 | "metadata": { 18 | "name": "John Doe", 19 | "username": "johndoe", 20 | "age": 25, 21 | "active": true, 22 | "tags": ["swift", "ios", "development"], 23 | "profile": { 24 | "bio": "Developer", 25 | "location": "San Francisco" 26 | } 27 | } 28 | } 29 | """ 30 | 31 | let data = json.data(using: .utf8)! 32 | let object = try JSONDecoder().decode(Object.self, from: data) 33 | 34 | XCTAssertEqual(object.title, "Test Object") 35 | XCTAssertNotNil(object.metadata) 36 | 37 | // Test accessing values via value(for:) method 38 | XCTAssertEqual(object.metafieldValue(for: "name")?.value as? String, "John Doe") 39 | XCTAssertEqual(object.metafieldValue(for: "username")?.value as? String, "johndoe") 40 | XCTAssertEqual(object.metafieldValue(for: "age")?.value as? Int, 25) 41 | XCTAssertEqual(object.metafieldValue(for: "active")?.value as? Bool, true) 42 | 43 | // Test accessing array values 44 | if let tags = object.metafieldValue(for: "tags")?.value as? [Any] { 45 | XCTAssertEqual(tags.count, 3) 46 | XCTAssertEqual(tags[0] as? String, "swift") 47 | } 48 | 49 | // Test accessing nested object values 50 | if let profile = object.metafieldValue(for: "profile")?.value as? [String: Any] { 51 | XCTAssertEqual(profile["bio"] as? String, "Developer") 52 | XCTAssertEqual(profile["location"] as? String, "San Francisco") 53 | } 54 | } 55 | 56 | func testDynamicMemberLookup() throws { 57 | let json = """ 58 | { 59 | "title": "Test Object", 60 | "metadata": { 61 | "name": "John Doe", 62 | "email": "john@example.com", 63 | "is_premium": true 64 | } 65 | } 66 | """ 67 | 68 | let data = json.data(using: .utf8)! 69 | let object = try JSONDecoder().decode(Object.self, from: data) 70 | 71 | // Test dynamic member lookup on metadata - direct comparison! 72 | XCTAssertEqual(object.metadata?.name, "John Doe") 73 | XCTAssertEqual(object.metadata?.email, "john@example.com") 74 | XCTAssertEqual(object.metadata?.is_premium, true) 75 | 76 | // Or if you need to store in variables 77 | let name = object.metadata?.name.string 78 | let email = object.metadata?.email.string 79 | let isPremium = object.metadata?.is_premium.bool 80 | 81 | XCTAssertEqual(name, "John Doe") 82 | XCTAssertEqual(email, "john@example.com") 83 | XCTAssertEqual(isPremium, true) 84 | 85 | // Test accessing non-existent property 86 | XCTAssertNil(object.metadata?.non_existent.string) 87 | XCTAssertFalse(object.metadata?.non_existent.exists ?? true) 88 | } 89 | 90 | func testBackwardCompatibilityWithArrayFormat() throws { 91 | let json = """ 92 | { 93 | "title": "Test Object", 94 | "metadata": [ 95 | { 96 | "type": "text", 97 | "title": "Name", 98 | "key": "name", 99 | "value": "John Doe" 100 | }, 101 | { 102 | "type": "text", 103 | "title": "Email", 104 | "key": "email", 105 | "value": "john@example.com" 106 | } 107 | ] 108 | } 109 | """ 110 | 111 | let data = json.data(using: .utf8)! 112 | let object = try JSONDecoder().decode(Object.self, from: data) 113 | 114 | // Should still work with array format 115 | XCTAssertEqual(object.metafieldValue(for: "name")?.value as? String, "John Doe") 116 | XCTAssertEqual(object.metafieldValue(for: "email")?.value as? String, "john@example.com") 117 | 118 | // Test accessing as array 119 | let fields = object.metafields 120 | XCTAssertNotNil(fields) 121 | XCTAssertEqual(fields?.count, 2) 122 | 123 | // Test accessing as dictionary 124 | let dict = object.metafieldsDict 125 | XCTAssertNotNil(dict) 126 | XCTAssertEqual(dict?.count, 2) 127 | } 128 | 129 | func testMixedContentTypes() throws { 130 | let json = """ 131 | { 132 | "title": "Event Object", 133 | "metadata": { 134 | "event_name": "CosmicCon 2024", 135 | "start_date": "2024-06-15", 136 | "attendee_count": 500, 137 | "is_virtual": false, 138 | "venue": { 139 | "name": "Convention Center", 140 | "address": "123 Main St" 141 | }, 142 | "speakers": ["Alice", "Bob", "Charlie"], 143 | "ticket_prices": { 144 | "early_bird": 99.99, 145 | "regular": 149.99, 146 | "vip": 299.99 147 | } 148 | } 149 | } 150 | """ 151 | 152 | let data = json.data(using: .utf8)! 153 | let object = try JSONDecoder().decode(Object.self, from: data) 154 | 155 | // Test various types - clean and type-safe! 156 | XCTAssertEqual(object.metadata?.event_name.string, "CosmicCon 2024") 157 | XCTAssertEqual(object.metadata?.attendee_count.int, 500) 158 | XCTAssertEqual(object.metadata?.is_virtual.bool, false) 159 | 160 | // Test nested objects and arrays 161 | if let speakers = object.metadata?.speakers.array(of: String.self) { 162 | XCTAssertEqual(speakers.count, 3) 163 | XCTAssertEqual(speakers[0], "Alice") 164 | } 165 | 166 | if let prices = object.metadata?.ticket_prices.dictionary(keyType: String.self, valueType: Any.self) { 167 | XCTAssertEqual(prices["early_bird"] as? Double, 99.99) 168 | } 169 | } 170 | 171 | func testEncodingDictionaryMetadata() throws { 172 | let json = """ 173 | { 174 | "title": "Test Object", 175 | "metadata": { 176 | "name": "John Doe", 177 | "active": true 178 | } 179 | } 180 | """ 181 | 182 | let data = json.data(using: .utf8)! 183 | let object = try JSONDecoder().decode(Object.self, from: data) 184 | 185 | // Re-encode and verify structure is preserved 186 | let encoder = JSONEncoder() 187 | encoder.outputFormatting = [.sortedKeys, .prettyPrinted] 188 | let encodedData = try encoder.encode(object) 189 | let encodedString = String(data: encodedData, encoding: .utf8)! 190 | 191 | // Should contain metadata as a dictionary 192 | XCTAssertTrue(encodedString.contains("\"metadata\" : {")) 193 | XCTAssertTrue(encodedString.contains("\"name\" : \"John Doe\"")) 194 | XCTAssertTrue(encodedString.contains("\"active\" : true")) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Cosmic dashboard darkmode 3 | 4 | 5 | # CosmicSDKSwift 6 | 7 | A pure Swift interpretation of the Cosmic SDK for use in Swift and SwiftUI projects. 8 | 9 | ## About this project 10 | 11 | This project is heavily inspired by our [JavaScript SDK](https://github.com/cosmicjs/cosmic-sdk-js) and [Adam Rushy's OpenAISwift](https://github.com/adamrushy/OpenAISwift/tree/main). 12 | 13 | Having built multiple Cosmic-powered SwiftUI apps, it felt time to provide a smart SDK that mapped as closely to our JavaScript SDK without moving away from common Swift conventions. 14 | 15 | [Cosmic](https://www.cosmicjs.com/) is a [headless CMS](https://www.cosmicjs.com/headless-cms) (content management system) that provides a web dashboard to create content and an API toolkit to deliver content to any website or application. Nearly any type of content can be built using the dashboard and delivered using this SDK. 16 | 17 | [Get started free →](https://app.cosmicjs.com/signup) 18 | 19 | ## Install 20 | 21 | ### Swift Package Manager 22 | 23 | You can use Swift Package Manager to integrate the SDK by adding the following dependency in the `Package.swift` file or by adding it directly within Xcode. 24 | 25 | `.package(url: "https://github.com/cosmicjs/cosmic-sdk-swift.git", from: "1.0.0")` 26 | 27 | ## Usage 28 | 29 | Import the framework in your project: 30 | 31 | `import CosmicSDK` 32 | 33 | You can get your API access keys by going to Bucket Settings > API Access in the [Cosmic dashboard](https://app.cosmicjs.com/login). 34 | 35 | ### Testing Your Connection 36 | 37 | If you're experiencing issues, you can test your connection first: 38 | 39 | ```swift 40 | // Test basic connection 41 | do { 42 | let response = try await cosmic.testConnection() 43 | print("Connection successful: \(response)") 44 | } catch { 45 | print("Connection failed: \(error)") 46 | } 47 | 48 | // Get bucket information to see available object types 49 | do { 50 | let bucketInfo = try await cosmic.getBucketInfo() 51 | print("Bucket title: \(bucketInfo.bucket.title)") 52 | // This will help you see what object types are available 53 | } catch { 54 | print("Failed to get bucket info: \(error)") 55 | } 56 | ``` 57 | 58 | ### Common Issues 59 | 60 | 1. **HTML Response Instead of JSON**: This usually means authentication failed 61 | 62 | - Check your bucket slug and read key 63 | - Ensure your read key has the correct permissions 64 | - Verify the bucket exists and is accessible 65 | 66 | 2. **Object Types Not Found**: Make sure the object types exist in your bucket 67 | 68 | - Check your Cosmic dashboard for available object types 69 | - Object type names are case-sensitive 70 | - Use `getBucketInfo()` to see available object types 71 | 72 | 3. **Empty Query Parameters**: The SDK now automatically filters out empty parameters 73 | 74 | 4. **Network Issues**: Ensure your app has internet connectivity 75 | 76 | ## TODO 77 | 78 | - [x] Add depth parameter to find methods 79 | - [x] Add skip parameter for pagination 80 | - [x] Update README with pagination examples 81 | 82 | ```swift 83 | let cosmic = CosmicSDKSwift( 84 | .createBucketClient( 85 | bucketSlug: BUCKET, 86 | readKey: READ_KEY, 87 | writeKey: WRITE_KEY 88 | ) 89 | ) 90 | ``` 91 | 92 | To see all the available methods, you can look at our [JavaScript implementation](https://www.cosmicjs.com/docs/api/) for now. This project is not at feature parity or feature complete, but the methods listed below are. 93 | 94 | From the SDK you can create your own state to hold your results, map a variable of any name to an array of type `Object` which is defined in our [model structure](https://www.cosmicjs.com/docs/api/objects#the-object-model). This is a singular `Object` that reflects any content model you create. 95 | 96 | ## Modern Swift Support 97 | 98 | The SDK now supports both completion handlers (for backward compatibility) and modern async/await patterns. Choose the style that best fits your project. 99 | 100 | ## Metadata Access 101 | 102 | The SDK now provides flexible metadata access with support for both the new dictionary format and legacy array format from the Cosmic API. 103 | 104 | ### Clean & Intuitive Access 105 | 106 | The new API provides the cleanest possible syntax for metadata access: 107 | 108 | ```swift 109 | // Direct comparisons - no casting needed! 110 | if user.metadata?.is_premium == true { 111 | // Premium user logic 112 | } 113 | 114 | if user.metadata?.name == "John Doe" { 115 | // Name matches 116 | } 117 | 118 | // Works in conditionals 119 | guard product.metadata?.in_stock == true else { 120 | return 121 | } 122 | 123 | // When you need to store values, use typed accessors 124 | let name = user.metadata?.name.string 125 | let age = user.metadata?.age.int 126 | let tags = user.metadata?.tags.array(of: String.self) 127 | ``` 128 | 129 | ### Three Ways to Access Metadata 130 | 131 | ```swift 132 | // 1. Direct comparison (cleanest for conditionals) 133 | if event.metadata?.is_virtual == true { } 134 | 135 | // 2. Typed accessors (when you need the value) 136 | let price = product.metadata?.price.double 137 | 138 | // 3. Nested access (for complex structures) 139 | let city = user.metadata?.address.city.string 140 | ``` 141 | 142 | Available type accessors: 143 | 144 | - `.string` - String values 145 | - `.int` - Integer values 146 | - `.double` - Double/Float values 147 | - `.bool` - Boolean values 148 | - `.array(of:)` - Typed arrays 149 | - `.dictionary(keyType:valueType:)` - Typed dictionaries 150 | - `.raw` - Access raw value for custom types 151 | - `.exists` - Check if field exists 152 | 153 | ### Real-World Examples 154 | 155 | ```swift 156 | // Fetch a user object 157 | let result = try await cosmic.findOne(type: "users", id: userId) 158 | let user = result.object 159 | 160 | // Direct comparisons - the cleanest syntax! 161 | if user.metadata?.is_premium == true { 162 | print("Welcome, premium user!") 163 | } 164 | 165 | if user.metadata?.account_type == "enterprise" { 166 | enableEnterpriseFeatures() 167 | } 168 | 169 | // When you need to store values 170 | let name = user.metadata?.name.string 171 | let email = user.metadata?.email.string 172 | let credits = user.metadata?.credits.int 173 | 174 | // Nested object access 175 | let city = user.metadata?.address.city.string 176 | let country = user.metadata?.address.country.string 177 | 178 | // Arrays with type safety 179 | if let roles = user.metadata?.roles.array(of: String.self) { 180 | print("User roles: \(roles.joined(separator: ", "))") 181 | } 182 | 183 | // Check field existence 184 | if user.metadata?.premium_expires.exists { 185 | // Handle premium expiration 186 | } 187 | ``` 188 | 189 | ### Alternative Access Methods 190 | 191 | ```swift 192 | // Method 1: Using metafieldValue (returns AnyCodable) 193 | let nameValue = user.metafieldValue(for: "name")?.value as? String 194 | 195 | // Method 2: Using metafieldsDict 196 | if let metadata = user.metafieldsDict { 197 | let name = metadata["name"]?.value as? String 198 | let email = metadata["email"]?.value as? String 199 | } 200 | 201 | // Method 3: For legacy support - access as array 202 | if let fields = user.metafields { 203 | for field in fields { 204 | print("\(field.key): \(field.value?.value ?? "nil")") 205 | } 206 | } 207 | ``` 208 | 209 | ### SwiftUI Example 210 | 211 | ```swift 212 | struct ProductView: View { 213 | let product: Object 214 | 215 | var body: some View { 216 | VStack { 217 | // Direct usage in SwiftUI views 218 | if product.metadata?.featured == true { 219 | Badge("Featured") 220 | .foregroundColor(.yellow) 221 | } 222 | 223 | Text(product.metadata?.name.string ?? "Unknown Product") 224 | .font(.title) 225 | 226 | if let price = product.metadata?.price.double { 227 | Text("$\(price, specifier: "%.2f")") 228 | .font(.headline) 229 | } 230 | 231 | // Conditional rendering based on stock 232 | if product.metadata?.in_stock == true { 233 | Button("Add to Cart") { 234 | addToCart() 235 | } 236 | } else { 237 | Text("Out of Stock") 238 | .foregroundColor(.gray) 239 | } 240 | 241 | // Display tags if available 242 | if let tags = product.metadata?.tags.array(of: String.self) { 243 | ScrollView(.horizontal) { 244 | HStack { 245 | ForEach(tags, id: \.self) { tag in 246 | TagView(tag: tag) 247 | } 248 | } 249 | } 250 | } 251 | } 252 | } 253 | } 254 | ``` 255 | 256 | ### Creating/Updating Objects with Metadata 257 | 258 | ```swift 259 | // Create new object with metadata 260 | let response = try await cosmic.insertOne( 261 | type: "products", 262 | title: "Premium Subscription", 263 | metadata: [ 264 | "price": 99.99, 265 | "currency": "USD", 266 | "features": ["Ad-free", "Priority support", "Advanced analytics"], 267 | "is_featured": true, 268 | "billing": [ 269 | "cycle": "monthly", 270 | "trial_days": 14 271 | ] 272 | ] 273 | ) 274 | 275 | // Update existing object metadata 276 | try await cosmic.updateOne( 277 | type: "products", 278 | id: productId, 279 | metadata: [ 280 | "price": 79.99, // Update price 281 | "sale_ends": "2024-12-31T23:59:59Z" 282 | ] 283 | ) 284 | ``` 285 | 286 | ### [Find](https://www.cosmicjs.com/docs/api/objects#get-objects) 287 | 288 | The SDK automatically formats requests to match the Cosmic API specification, including proper query parameters and authentication. 289 | 290 | ### Pagination 291 | 292 | The `find` method supports pagination using the `limit` and `skip` parameters: 293 | 294 | - **`limit`**: Number of objects to return (default: 10) 295 | - **`skip`**: Number of objects to skip before returning results (default: 0) 296 | 297 | This allows you to implement pagination in your app: 298 | 299 | **Using Async/Await (Recommended):** 300 | 301 | ```swift 302 | @State var objects: [Object] = [] 303 | 304 | Task { 305 | do { 306 | let result = try await cosmic.find(type: TYPE) 307 | self.objects = result.objects 308 | } catch { 309 | print("Error: \(error)") 310 | } 311 | } 312 | 313 | // With pagination 314 | Task { 315 | do { 316 | // Get first 10 objects 317 | let firstPage = try await cosmic.find(type: TYPE, limit: 10, skip: 0) 318 | 319 | // Get next 10 objects 320 | let secondPage = try await cosmic.find(type: TYPE, limit: 10, skip: 10) 321 | 322 | self.objects = firstPage.objects + secondPage.objects 323 | } catch { 324 | print("Error: \(error)") 325 | } 326 | } 327 | ``` 328 | 329 | **Using Completion Handlers:** 330 | 331 | ```swift 332 | @State var objects: [Object] = [] 333 | 334 | cosmic.find(type: TYPE) { results in 335 | switch results { 336 | case .success(let result): 337 | self.objects = result.objects 338 | case .failure(let error): 339 | print(error) 340 | } 341 | } 342 | ``` 343 | 344 | With optional props, limit, sorting and status parameters: 345 | 346 | **Async/Await:** 347 | 348 | ```swift 349 | let result = try await cosmic.find( 350 | type: TYPE, 351 | props: "metadata.image.imgix_url,slug", 352 | limit: 10, // Now accepts Int instead of String 353 | sort: .random, 354 | status: .any // Query for both published and draft objects 355 | ) 356 | self.objects = result.objects 357 | ``` 358 | 359 | **Completion Handler:** 360 | 361 | ```swift 362 | cosmic.find( 363 | type: TYPE, 364 | props: "metadata.image.imgix_url,slug", 365 | limit: 10, 366 | sort: .random, 367 | status: .any 368 | ) { results in 369 | switch results { 370 | case .success(let result): 371 | self.objects = result.objects 372 | case .failure(let error): 373 | print(error) 374 | } 375 | } 376 | ``` 377 | 378 | ### Regex queries 379 | 380 | Use `findRegex` for server-side regex filtering (mirrors the JavaScript SDK's `$regex` / `$options`). Supports nested fields like `metadata.brand`. 381 | 382 | **Async/Await:** 383 | 384 | ```swift 385 | // Case-insensitive title match 386 | let hoodieResults = try await cosmic.findRegex( 387 | type: "products", 388 | field: "title", 389 | pattern: "Hoodie", // regex pattern 390 | options: [.caseInsensitive], // defaults to case-insensitive 391 | limit: 10 392 | ) 393 | 394 | // Regex on nested metadata field 395 | let brandResults = try await cosmic.findRegex( 396 | type: "products", 397 | field: "metadata.brand", 398 | pattern: "^acme$", 399 | options: [.caseInsensitive] 400 | ) 401 | ``` 402 | 403 | **Completion Handler:** 404 | 405 | ```swift 406 | cosmic.findRegex( 407 | type: "products", 408 | field: "title", 409 | pattern: "Hoodie", 410 | options: [.caseInsensitive], 411 | limit: 10 412 | ) { results in 413 | switch results { 414 | case .success(let result): 415 | print("Found: \(result.objects.count)") 416 | case .failure(let error): 417 | print(error) 418 | } 419 | } 420 | ``` 421 | 422 | ### MongoDB-Style Query Filters 423 | 424 | The `find` method now supports MongoDB-style query filters, allowing you to build complex queries with operators like `$in`, `$eq`, `$gt`, `$lt`, `$exists`, and more. This is particularly useful for filtering by relationship IDs in nested metadata fields. 425 | 426 | **Async/Await:** 427 | 428 | ```swift 429 | // Filter by single relationship ID 430 | let episodes = try await cosmic.find( 431 | type: "episode", 432 | query: ["metadata.regular_hosts.id": "host-id-123"], 433 | depth: 2 434 | ) 435 | 436 | // Filter by multiple relationship IDs using $in operator 437 | let episodes = try await cosmic.find( 438 | type: "episode", 439 | query: [ 440 | "metadata.regular_hosts.id": ["$in": ["host-id-1", "host-id-2", "host-id-3"]] 441 | ], 442 | props: "id,slug,title,content,metadata", 443 | limit: 20, 444 | depth: 2 445 | ) 446 | 447 | // Filter by date range 448 | let recentEpisodes = try await cosmic.find( 449 | type: "episode", 450 | query: [ 451 | "metadata.broadcast_date": ["$gte": "2024-01-01"] 452 | ], 453 | limit: 10 454 | ) 455 | 456 | // Check field existence 457 | let episodesWithTakeovers = try await cosmic.find( 458 | type: "episode", 459 | query: [ 460 | "metadata.takeovers": ["$exists": true] 461 | ] 462 | ) 463 | 464 | // Combine multiple filters 465 | let filteredEpisodes = try await cosmic.find( 466 | type: "episode", 467 | query: [ 468 | "metadata.regular_hosts.id": ["$in": ["host-1", "host-2"]], 469 | "metadata.broadcast_date": ["$gte": "2024-01-01", "$lte": "2024-12-31"], 470 | "status": "published" 471 | ], 472 | depth: 2 473 | ) 474 | ``` 475 | 476 | **Completion Handler:** 477 | 478 | ```swift 479 | cosmic.find( 480 | type: "episode", 481 | query: [ 482 | "metadata.regular_hosts.id": ["$in": ["host-id-1", "host-id-2"]] 483 | ], 484 | depth: 2 485 | ) { results in 486 | switch results { 487 | case .success(let result): 488 | print("Found \(result.objects.count) episodes") 489 | case .failure(let error): 490 | print(error) 491 | } 492 | } 493 | ``` 494 | 495 | **Supported MongoDB Operators:** 496 | 497 | - `$in`: Value is in array 498 | - `$nin`: Value is not in array 499 | - `$eq`: Equal to (can also use direct value) 500 | - `$ne`: Not equal to 501 | - `$gt`: Greater than 502 | - `$gte`: Greater than or equal 503 | - `$lt`: Less than 504 | - `$lte`: Less than or equal 505 | - `$exists`: Field exists 506 | - `$regex`: Regular expression match (or use `findRegex` for a more convenient API) 507 | 508 | **Benefits Over Regex:** 509 | 510 | When querying by relationship IDs, MongoDB-style filters are more efficient than regex patterns: 511 | 512 | ```swift 513 | // Less efficient: Using regex 514 | let episodes = try await cosmic.findRegex( 515 | type: "episode", 516 | field: "metadata.regular_hosts.id", 517 | pattern: "^\(hostId)$", // Exact match regex 518 | depth: 2 519 | ) 520 | 521 | // More efficient: Using query filters 522 | let episodes = try await cosmic.find( 523 | type: "episode", 524 | query: ["metadata.regular_hosts.id": hostId], 525 | depth: 2 526 | ) 527 | ``` 528 | 529 | ### [Find One](https://www.cosmicjs.com/docs/api/objects#get-a-single-object-by-id) 530 | 531 | **Async/Await:** 532 | 533 | ```swift 534 | @State private var object: Object? 535 | 536 | do { 537 | let result = try await cosmic.findOne(type: TYPE, id: objectId) 538 | self.object = result.object 539 | } catch { 540 | print("Error: \(error)") 541 | } 542 | ``` 543 | 544 | **Completion Handler:** 545 | 546 | ```swift 547 | cosmic.findOne(type: TYPE, id: objectId) { results in 548 | switch results { 549 | case .success(let result): 550 | self.object = result.object 551 | case .failure(let error): 552 | print(error) 553 | } 554 | } 555 | ``` 556 | 557 | You can't initialize a single Object with a specific type, so instead, mark as optional and handle the optionality accordingly. 558 | 559 | ```swift 560 | if let object = object { 561 | Text(object.title) 562 | } 563 | ``` 564 | 565 | ### [Insert One](https://www.cosmicjs.com/docs/api/objects#create-an-object) 566 | 567 | `.insertOne()` adds a new Object to your Cosmic Bucket. Use this for adding a new Object to an existing Object Type. 568 | 569 | **Async/Await:** 570 | 571 | ```swift 572 | do { 573 | let response = try await cosmic.insertOne( 574 | type: TYPE, 575 | title: "New Object Title" 576 | ) 577 | print("Created successfully: \(response.message ?? "")") 578 | } catch { 579 | print("Error: \(error)") 580 | } 581 | ``` 582 | 583 | **Completion Handler:** 584 | 585 | ```swift 586 | cosmic.insertOne( 587 | type: TYPE, 588 | title: "New Object Title" 589 | ) { results in 590 | switch results { 591 | case .success(let response): 592 | print("Created successfully") 593 | case .failure(let error): 594 | print(error) 595 | } 596 | } 597 | ``` 598 | 599 | With optional props for content, metadata and slug: 600 | 601 | **Async/Await:** 602 | 603 | ```swift 604 | let response = try await cosmic.insertOne( 605 | type: TYPE, 606 | title: "New Product", 607 | slug: "new-product", 608 | content: "Product description here", 609 | metadata: [ 610 | "price": 49.99, 611 | "sku": "PROD-001", 612 | "in_stock": true, 613 | "categories": ["Electronics", "Gadgets"] 614 | ] 615 | ) 616 | print("Created object with ID: \(response.message ?? "")") 617 | ``` 618 | 619 | **Completion Handler:** 620 | 621 | ```swift 622 | cosmic.insertOne( 623 | type: TYPE, 624 | title: "New Product", 625 | content: "Product description", 626 | metadata: ["key": "value"], 627 | slug: "new-product" 628 | ) { results in 629 | switch results { 630 | case .success(let response): 631 | print("Created successfully") 632 | case .failure(let error): 633 | print(error) 634 | } 635 | } 636 | ``` 637 | 638 | ### [Update One](https://www.cosmicjs.com/docs/api/objects#update-an-object) 639 | 640 | When using `.updateOne()` you can update an Object's metadata by passing the optional metadata dictionary with one, or many, `key:value` pairs. 641 | 642 | **Async/Await:** 643 | 644 | ```swift 645 | do { 646 | let response = try await cosmic.updateOne( 647 | type: TYPE, 648 | id: objectId, 649 | title: "Updated Title", 650 | metadata: ["last_updated": Date().ISO8601Format()] 651 | ) 652 | print("Updated successfully") 653 | } catch { 654 | print("Error: \(error)") 655 | } 656 | ``` 657 | 658 | **Completion Handler:** 659 | 660 | ```swift 661 | cosmic.updateOne( 662 | type: TYPE, 663 | id: objectId, 664 | title: "Updated Title", 665 | content: "New content", 666 | metadata: ["key": "value"], 667 | status: .published 668 | ) { results in 669 | switch results { 670 | case .success(_): 671 | print("Updated successfully") 672 | case .failure(let error): 673 | print(error) 674 | } 675 | } 676 | ``` 677 | 678 | ### [Delete One](https://www.cosmicjs.com/docs/api/objects#delete-an-object) 679 | 680 | **Async/Await:** 681 | 682 | ```swift 683 | do { 684 | let response = try await cosmic.deleteOne(type: TYPE, id: objectId) 685 | print("Deleted successfully") 686 | } catch { 687 | print("Error: \(error)") 688 | } 689 | ``` 690 | 691 | **Completion Handler:** 692 | 693 | ```swift 694 | cosmic.deleteOne(type: TYPE, id: objectId) { results in 695 | switch results { 696 | case .success(_): 697 | print("Deleted successfully") 698 | case .failure(let error): 699 | print(error) 700 | } 701 | } 702 | ``` 703 | 704 | Depending on how you handle your data, you will have to account for `id` being a required parameter in the API. 705 | 706 | ## New Features 707 | 708 | ### Scheduled Publishing 709 | 710 | You can now schedule objects to be published or unpublished at specific dates: 711 | 712 | ```swift 713 | // Schedule publish date 714 | cosmic.insertOne( 715 | type: "posts", 716 | title: "Holiday Sale", 717 | publish_at: "2024-12-25T00:00:00.000Z" // ISO 8601 format 718 | ) { ... } 719 | 720 | // Schedule unpublish date 721 | cosmic.updateOne( 722 | type: "events", 723 | id: eventId, 724 | title: "Limited Time Offer", 725 | unpublish_at: "2024-12-31T23:59:59.000Z" 726 | ) { ... } 727 | ``` 728 | 729 | Note: Objects with `publish_at` or `unpublish_at` dates are automatically saved as drafts. 730 | 731 | ### Query Any Status 732 | 733 | Use `.any` status to query both published and draft objects: 734 | 735 | ```swift 736 | cosmic.find( 737 | type: "posts", 738 | status: .any // Returns both published and draft objects 739 | ) { ... } 740 | ``` 741 | 742 | ## Migration Guide 743 | 744 | ### Limit Parameter Change 745 | 746 | The `limit` parameter now accepts `Int` instead of `String`. If you have existing code using String limits: 747 | 748 | ```swift 749 | // Old code 750 | cosmic.find(type: "posts", limit: "10") { ... } 751 | 752 | // New code 753 | cosmic.find(type: "posts", limit: 10) { ... } 754 | 755 | // If you have a String variable 756 | let stringLimit = "10" 757 | cosmic.find(type: "posts", limit: Int(stringLimit) ?? 10) { ... } 758 | ``` 759 | 760 | ## License 761 | 762 | The MIT License (MIT) 763 | 764 | Copyright (c) 2024 CosmicJS 765 | 766 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 767 | 768 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 769 | 770 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 771 | -------------------------------------------------------------------------------- /Sources/CosmicSDK/Model.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model.swift 3 | // 4 | // 5 | // Created by Karl Koch on 19/09/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct AnyCodable: Codable { 11 | public var value: Any 12 | 13 | public init(value: Any) { 14 | self.value = value 15 | } 16 | 17 | public init(from decoder: Decoder) throws { 18 | let container = try decoder.singleValueContainer() 19 | if container.decodeNil() { 20 | value = NSNull() // Here we handle null values 21 | } else if let bool = try? container.decode(Bool.self) { 22 | value = bool 23 | } else if let int = try? container.decode(Int.self) { 24 | value = int 25 | } else if let string = try? container.decode(String.self) { 26 | value = string 27 | } else if let array = try? container.decode([AnyCodable].self) { 28 | value = array.map { $0.value } 29 | } else if let dictionary = try? container.decode([String: AnyCodable].self) { 30 | value = dictionary.mapValues { $0.value } 31 | } else { 32 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable can't decode the value") 33 | } 34 | } 35 | 36 | public func encode(to encoder: Encoder) throws { 37 | var container = encoder.singleValueContainer() 38 | if value is NSNull { 39 | try container.encodeNil() 40 | } else if let bool = value as? Bool { 41 | try container.encode(bool) 42 | } else if let int = value as? Int { 43 | try container.encode(int) 44 | } else if let double = value as? Double { 45 | try container.encode(double) 46 | } else if let string = value as? String { 47 | try container.encode(string) 48 | } else if let array = value as? [Any] { 49 | // ensure all elements in array are AnyCodable 50 | try container.encode(array.map { 51 | guard let val = $0 as? AnyCodable else { 52 | throw EncodingError.invalidValue($0, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Invalid array element")) 53 | } 54 | return val 55 | }) 56 | } else if let dictionary = value as? [String: Any] { 57 | let filteredDictionary = dictionary.compactMapValues { $0 as? AnyCodable } 58 | try container.encode(filteredDictionary) 59 | } 60 | } 61 | } 62 | 63 | public struct CosmicSDK: Codable { 64 | public let objects: [Object] 65 | 66 | public init(from decoder: Decoder) throws { 67 | let container = try decoder.container(keyedBy: CodingKeys.self) 68 | // Handle cases where objects might be missing (e.g., 404 "no objects found" responses) 69 | self.objects = (try? container.decode([Object].self, forKey: .objects)) ?? [] 70 | } 71 | 72 | private enum CodingKeys: String, CodingKey { 73 | case objects 74 | } 75 | } 76 | 77 | public struct CosmicSDKSingle: Codable { 78 | public let object: Object 79 | } 80 | 81 | /// Response type for insertOne that returns the created object 82 | /// This allows access to the newly created object's id, slug, and other properties 83 | public struct InsertOneResponse: Codable { 84 | public let object: Object 85 | } 86 | 87 | // MARK: - Metafield Models 88 | public enum MetafieldType: String, Codable { 89 | case text = "text" 90 | case textarea = "textarea" 91 | case htmlTextarea = "html-textarea" 92 | case markdown = "markdown" 93 | case selectDropdown = "select-dropdown" 94 | case object = "object" 95 | case objects = "objects" 96 | case file = "file" 97 | case files = "files" 98 | case date = "date" 99 | case json = "json" 100 | case radioButtons = "radio-buttons" 101 | case checkBoxes = "check-boxes" 102 | case `switch` = "switch" 103 | case color = "color" 104 | case parent = "parent" 105 | case repeater = "repeater" 106 | } 107 | 108 | public struct MetafieldOption: Codable { 109 | public let key: String? 110 | public let value: String 111 | } 112 | 113 | public struct RepeaterField: Codable { 114 | public let title: String 115 | public let key: String 116 | public let value: String? 117 | public let type: MetafieldType 118 | public let required: Bool? 119 | } 120 | 121 | public struct Metafield: Codable { 122 | public let type: MetafieldType 123 | public let title: String 124 | public let key: String 125 | public var value: AnyCodable? 126 | public let required: Bool? 127 | 128 | // For select-dropdown, radio-buttons, check-boxes 129 | public let options: [MetafieldOption]? 130 | 131 | // For object and objects 132 | public let object_type: String? 133 | 134 | // For parent 135 | public let children: [Metafield]? 136 | 137 | // For repeater 138 | public let repeater_fields: [RepeaterField]? 139 | 140 | private enum CodingKeys: String, CodingKey { 141 | case type, title, key, value, required, options, object_type, children, repeater_fields 142 | } 143 | 144 | public init(from decoder: Decoder) throws { 145 | let container = try decoder.container(keyedBy: CodingKeys.self) 146 | type = try container.decode(MetafieldType.self, forKey: .type) 147 | title = try container.decode(String.self, forKey: .title) 148 | key = try container.decode(String.self, forKey: .key) 149 | required = try container.decodeIfPresent(Bool.self, forKey: .required) 150 | options = try container.decodeIfPresent([MetafieldOption].self, forKey: .options) 151 | object_type = try container.decodeIfPresent(String.self, forKey: .object_type) 152 | children = try container.decodeIfPresent([Metafield].self, forKey: .children) 153 | repeater_fields = try container.decodeIfPresent([RepeaterField].self, forKey: .repeater_fields) 154 | 155 | // Handle value based on type 156 | if type == .objects || type == .files || type == .checkBoxes, 157 | var valueContainer = try? container.nestedUnkeyedContainer(forKey: .value) { 158 | var array: [AnyCodable] = [] 159 | while !valueContainer.isAtEnd { 160 | if let value = try? valueContainer.decode(AnyCodable.self) { 161 | array.append(value) 162 | } 163 | } 164 | value = AnyCodable(value: array) 165 | } else if type == .json, 166 | let jsonValue = try? container.decode([String: AnyCodable].self, forKey: .value) { 167 | value = AnyCodable(value: jsonValue) 168 | } else if type == .switch, 169 | let boolValue = try? container.decode(Bool.self, forKey: .value) { 170 | value = AnyCodable(value: boolValue) 171 | } else { 172 | value = try container.decodeIfPresent(AnyCodable.self, forKey: .value) 173 | } 174 | } 175 | 176 | public func encode(to encoder: Encoder) throws { 177 | var container = encoder.container(keyedBy: CodingKeys.self) 178 | try container.encode(type, forKey: .type) 179 | try container.encode(title, forKey: .title) 180 | try container.encode(key, forKey: .key) 181 | try container.encodeIfPresent(required, forKey: .required) 182 | try container.encodeIfPresent(options, forKey: .options) 183 | try container.encodeIfPresent(object_type, forKey: .object_type) 184 | try container.encodeIfPresent(children, forKey: .children) 185 | try container.encodeIfPresent(repeater_fields, forKey: .repeater_fields) 186 | try container.encodeIfPresent(value, forKey: .value) 187 | } 188 | } 189 | 190 | // MARK: - MetadataValue 191 | /// A transparent wrapper that provides seamless access to metadata values 192 | @dynamicMemberLookup 193 | public struct MetadataValue: CustomStringConvertible { 194 | private let value: Any? 195 | 196 | init(value: Any?) { 197 | self.value = value 198 | } 199 | 200 | /// Implicit conversion to optional String 201 | public var stringValue: String? { 202 | return value as? String 203 | } 204 | 205 | /// Implicit conversion to optional Int 206 | public var intValue: Int? { 207 | return value as? Int 208 | } 209 | 210 | /// Implicit conversion to optional Double 211 | public var doubleValue: Double? { 212 | return value as? Double 213 | } 214 | 215 | /// Implicit conversion to optional Bool 216 | public var boolValue: Bool? { 217 | return value as? Bool 218 | } 219 | 220 | /// Direct access as specific type - for backward compatibility 221 | public var string: String? { stringValue } 222 | public var int: Int? { intValue } 223 | public var double: Double? { doubleValue } 224 | public var bool: Bool? { boolValue } 225 | 226 | /// Array value with generic type 227 | public func array(of type: T.Type = T.self) -> [T]? { 228 | return value as? [T] 229 | } 230 | 231 | /// Dictionary value with generic types 232 | public func dictionary(keyType: K.Type = K.self, valueType: V.Type = V.self) -> [K: V]? { 233 | return value as? [K: V] 234 | } 235 | 236 | /// Raw value for custom casting 237 | public var raw: Any? { 238 | return value 239 | } 240 | 241 | /// Check if value exists (not nil) 242 | public var exists: Bool { 243 | return value != nil 244 | } 245 | 246 | /// String description 247 | public var description: String { 248 | return String(describing: value) 249 | } 250 | 251 | /// Dynamic member lookup for nested objects 252 | public subscript(dynamicMember key: String) -> MetadataValue { 253 | if let dict = value as? [String: Any] { 254 | return MetadataValue(value: dict[key]) 255 | } 256 | return MetadataValue(value: nil) 257 | } 258 | } 259 | 260 | // MARK: - MetadataValue Extensions 261 | extension MetadataValue: ExpressibleByStringLiteral { 262 | public init(stringLiteral value: String) { 263 | self.init(value: value) 264 | } 265 | } 266 | 267 | extension MetadataValue: ExpressibleByIntegerLiteral { 268 | public init(integerLiteral value: Int) { 269 | self.init(value: value) 270 | } 271 | } 272 | 273 | extension MetadataValue: ExpressibleByFloatLiteral { 274 | public init(floatLiteral value: Double) { 275 | self.init(value: value) 276 | } 277 | } 278 | 279 | extension MetadataValue: ExpressibleByBooleanLiteral { 280 | public init(booleanLiteral value: Bool) { 281 | self.init(value: value) 282 | } 283 | } 284 | 285 | // Allow direct comparison 286 | extension MetadataValue: Equatable { 287 | public static func == (lhs: MetadataValue, rhs: MetadataValue) -> Bool { 288 | // String comparison 289 | if let lhsString = lhs.stringValue, let rhsString = rhs.stringValue { 290 | return lhsString == rhsString 291 | } 292 | // Int comparison 293 | if let lhsInt = lhs.intValue, let rhsInt = rhs.intValue { 294 | return lhsInt == rhsInt 295 | } 296 | // Double comparison 297 | if let lhsDouble = lhs.doubleValue, let rhsDouble = rhs.doubleValue { 298 | return lhsDouble == rhsDouble 299 | } 300 | // Bool comparison 301 | if let lhsBool = lhs.boolValue, let rhsBool = rhs.boolValue { 302 | return lhsBool == rhsBool 303 | } 304 | // Both nil 305 | return lhs.value == nil && rhs.value == nil 306 | } 307 | 308 | // Allow comparison with literals 309 | public static func == (lhs: MetadataValue, rhs: String) -> Bool { 310 | return lhs.stringValue == rhs 311 | } 312 | 313 | public static func == (lhs: MetadataValue, rhs: Int) -> Bool { 314 | return lhs.intValue == rhs 315 | } 316 | 317 | public static func == (lhs: MetadataValue, rhs: Double) -> Bool { 318 | return lhs.doubleValue == rhs 319 | } 320 | 321 | public static func == (lhs: MetadataValue, rhs: Bool) -> Bool { 322 | return lhs.boolValue == rhs 323 | } 324 | } 325 | 326 | // MARK: - ObjectMetadata 327 | /// A wrapper for metadata that can handle both array and dictionary formats from the API 328 | @dynamicMemberLookup 329 | public struct ObjectMetadata: Codable { 330 | private let storage: MetadataStorage 331 | 332 | private enum MetadataStorage { 333 | case array([Metafield]) 334 | case dictionary([String: AnyCodable]) 335 | } 336 | 337 | public init(from decoder: Decoder) throws { 338 | let container = try decoder.singleValueContainer() 339 | 340 | // Try to decode as array first (backward compatibility) 341 | if let array = try? container.decode([Metafield].self) { 342 | storage = .array(array) 343 | } 344 | // Then try as dictionary (new format) 345 | else if let dict = try? container.decode([String: AnyCodable].self) { 346 | storage = .dictionary(dict) 347 | } 348 | // If neither works, check if it's empty/null 349 | else if container.decodeNil() { 350 | storage = .dictionary([:]) 351 | } else { 352 | throw DecodingError.typeMismatch( 353 | ObjectMetadata.self, 354 | DecodingError.Context( 355 | codingPath: decoder.codingPath, 356 | debugDescription: "Expected to decode Array or Dictionary for metadata" 357 | ) 358 | ) 359 | } 360 | } 361 | 362 | public func encode(to encoder: Encoder) throws { 363 | var container = encoder.singleValueContainer() 364 | switch storage { 365 | case .array(let array): 366 | try container.encode(array) 367 | case .dictionary(let dict): 368 | try container.encode(dict) 369 | } 370 | } 371 | 372 | /// Access metadata fields as an array (for backward compatibility) 373 | public var fields: [Metafield]? { 374 | switch storage { 375 | case .array(let array): 376 | return array 377 | case .dictionary(let dict): 378 | // Convert dictionary to array of Metafield objects 379 | return dict.map { key, value in 380 | Metafield( 381 | type: .text, // Default type when converting from dictionary 382 | title: key.replacingOccurrences(of: "_", with: " ").capitalized, 383 | key: key, 384 | value: value, 385 | required: false, 386 | options: nil, 387 | object_type: nil, 388 | children: nil, 389 | repeater_fields: nil 390 | ) 391 | } 392 | } 393 | } 394 | 395 | /// Access metadata fields as a dictionary 396 | public var dict: [String: AnyCodable]? { 397 | switch storage { 398 | case .array(let array): 399 | var dict: [String: AnyCodable] = [:] 400 | for field in array { 401 | if let value = field.value { 402 | dict[field.key] = value 403 | } 404 | } 405 | return dict.isEmpty ? nil : dict 406 | case .dictionary(let dict): 407 | return dict.isEmpty ? nil : dict 408 | } 409 | } 410 | 411 | /// Get value for a specific key 412 | public func value(for key: String) -> AnyCodable? { 413 | switch storage { 414 | case .array(let array): 415 | return array.first(where: { $0.key == key })?.value 416 | case .dictionary(let dict): 417 | return dict[key] 418 | } 419 | } 420 | 421 | /// Dynamic member lookup returns a MetadataValue wrapper for flexible access 422 | public subscript(dynamicMember key: String) -> MetadataValue { 423 | return MetadataValue(value: value(for: key)?.value) 424 | } 425 | 426 | /// Direct string access helper 427 | public func string(_ key: String) -> String? { 428 | return value(for: key)?.value as? String 429 | } 430 | 431 | /// Direct int access helper 432 | public func int(_ key: String) -> Int? { 433 | return value(for: key)?.value as? Int 434 | } 435 | 436 | /// Direct double access helper 437 | public func double(_ key: String) -> Double? { 438 | return value(for: key)?.value as? Double 439 | } 440 | 441 | /// Direct bool access helper 442 | public func bool(_ key: String) -> Bool? { 443 | return value(for: key)?.value as? Bool 444 | } 445 | 446 | /// Initialize with custom decoding for Metafield 447 | private init(type: MetafieldType, title: String, key: String, value: AnyCodable?, required: Bool?, options: [MetafieldOption]?, object_type: String?, children: [Metafield]?, repeater_fields: [RepeaterField]?) { 448 | // This is a helper initializer used when converting dictionary to array format 449 | let metafield = Metafield( 450 | type: type, 451 | title: title, 452 | key: key, 453 | value: value, 454 | required: required, 455 | options: options, 456 | object_type: object_type, 457 | children: children, 458 | repeater_fields: repeater_fields 459 | ) 460 | storage = .array([metafield]) 461 | } 462 | } 463 | 464 | // Custom initializer for Metafield to support the conversion 465 | extension Metafield { 466 | init(type: MetafieldType, title: String, key: String, value: AnyCodable?, required: Bool?, options: [MetafieldOption]?, object_type: String?, children: [Metafield]?, repeater_fields: [RepeaterField]?) { 467 | self.type = type 468 | self.title = title 469 | self.key = key 470 | self.value = value 471 | self.required = required 472 | self.options = options 473 | self.object_type = object_type 474 | self.children = children 475 | self.repeater_fields = repeater_fields 476 | } 477 | } 478 | 479 | // Object model with structured metadata 480 | public struct Object: Codable { 481 | public let id: String? 482 | public let slug: String? 483 | public let title: String 484 | public let content: String? 485 | public let bucket: String? 486 | public let created_at: String? 487 | public let created_by: String? 488 | public let modified_at: String? 489 | public let modified_by: String? 490 | public let status: String? 491 | public let published_at: String? 492 | public let publish_at: String? 493 | public let unpublish_at: String? 494 | public let type: String? 495 | public let locale: String? 496 | public let thumbnail: String? 497 | public let metadata: ObjectMetadata? 498 | 499 | enum CodingKeys: String, CodingKey { 500 | case id, slug, title, content, bucket, created_at, created_by, modified_at, modified_by, status, published_at, publish_at, unpublish_at, type, locale, thumbnail, metadata, metafields 501 | } 502 | 503 | // Custom decoder to gracefully handle String or numeric publish_at fields 504 | public init(from decoder: Decoder) throws { 505 | let container = try decoder.container(keyedBy: CodingKeys.self) 506 | id = try container.decodeIfPresent(String.self, forKey: .id) 507 | slug = try container.decodeIfPresent(String.self, forKey: .slug) 508 | title = try container.decode(String.self, forKey: .title) 509 | content = try container.decodeIfPresent(String.self, forKey: .content) 510 | bucket = try container.decodeIfPresent(String.self, forKey: .bucket) 511 | created_at = try container.decodeIfPresent(String.self, forKey: .created_at) 512 | created_by = try container.decodeIfPresent(String.self, forKey: .created_by) 513 | modified_at = try container.decodeIfPresent(String.self, forKey: .modified_at) 514 | modified_by = try container.decodeIfPresent(String.self, forKey: .modified_by) 515 | status = try container.decodeIfPresent(String.self, forKey: .status) 516 | published_at = try Object.decodeStringOrNumber(from: container, forKey: .published_at) 517 | publish_at = try Object.decodeStringOrNumber(from: container, forKey: .publish_at) 518 | unpublish_at = try Object.decodeStringOrNumber(from: container, forKey: .unpublish_at) 519 | type = try container.decodeIfPresent(String.self, forKey: .type) 520 | locale = try container.decodeIfPresent(String.self, forKey: .locale) 521 | thumbnail = try container.decodeIfPresent(String.self, forKey: .thumbnail) 522 | 523 | // Try to decode from 'metadata' first, then fall back to 'metafields' for backward compatibility 524 | if container.contains(.metadata) { 525 | metadata = try container.decodeIfPresent(ObjectMetadata.self, forKey: .metadata) 526 | } else if container.contains(.metafields) { 527 | // If we have metafields, decode as array and wrap in ObjectMetadata 528 | if let metafieldsArray = try container.decodeIfPresent([Metafield].self, forKey: .metafields) { 529 | let encoder = JSONEncoder() 530 | let decoder = JSONDecoder() 531 | let data = try encoder.encode(metafieldsArray) 532 | metadata = try decoder.decode(ObjectMetadata.self, from: data) 533 | } else { 534 | metadata = nil 535 | } 536 | } else { 537 | metadata = nil 538 | } 539 | } 540 | 541 | // We rarely encode Object back to JSON in the SDK. Implement minimal encoder. 542 | public func encode(to encoder: Encoder) throws { 543 | var container = encoder.container(keyedBy: CodingKeys.self) 544 | try container.encodeIfPresent(id, forKey: .id) 545 | try container.encodeIfPresent(slug, forKey: .slug) 546 | try container.encode(title, forKey: .title) 547 | try container.encodeIfPresent(content, forKey: .content) 548 | try container.encodeIfPresent(bucket, forKey: .bucket) 549 | try container.encodeIfPresent(created_at, forKey: .created_at) 550 | try container.encodeIfPresent(created_by, forKey: .created_by) 551 | try container.encodeIfPresent(modified_at, forKey: .modified_at) 552 | try container.encodeIfPresent(modified_by, forKey: .modified_by) 553 | try container.encodeIfPresent(status, forKey: .status) 554 | try container.encodeIfPresent(published_at, forKey: .published_at) 555 | try container.encodeIfPresent(publish_at, forKey: .publish_at) 556 | try container.encodeIfPresent(unpublish_at, forKey: .unpublish_at) 557 | try container.encodeIfPresent(type, forKey: .type) 558 | try container.encodeIfPresent(locale, forKey: .locale) 559 | try container.encodeIfPresent(thumbnail, forKey: .thumbnail) 560 | try container.encodeIfPresent(metadata, forKey: .metadata) 561 | } 562 | 563 | // Helper to decode String or numeric value 564 | private static func decodeStringOrNumber(from container: KeyedDecodingContainer, forKey key: CodingKeys) throws -> String? { 565 | if let stringVal = try? container.decodeIfPresent(String.self, forKey: key) { 566 | return stringVal 567 | } 568 | if let intVal = try? container.decodeIfPresent(Int.self, forKey: key) { 569 | return String(intVal) 570 | } 571 | if let doubleVal = try? container.decodeIfPresent(Double.self, forKey: key) { 572 | return String(doubleVal) 573 | } 574 | return nil 575 | } 576 | } 577 | 578 | struct Command: Codable { 579 | public let title: String 580 | public let slug: String? 581 | public let content: String? 582 | public let metadata: [String: AnyCodable]? 583 | } 584 | 585 | // MARK: - Object Extensions 586 | extension Object { 587 | /// Access metafield by key for easier usage 588 | public func metafieldValue(for key: String) -> AnyCodable? { 589 | return metadata?.value(for: key) 590 | } 591 | 592 | /// Get all metafields as a dictionary for convenience 593 | public var metafieldsDict: [String: AnyCodable]? { 594 | return metadata?.dict 595 | } 596 | 597 | /// Get all metafields as an array (for backward compatibility) 598 | public var metafields: [Metafield]? { 599 | return metadata?.fields 600 | } 601 | } 602 | 603 | // MARK: - Media Models 604 | public struct CosmicMedia: Codable { 605 | public let id: String 606 | public let name: String 607 | public let original_name: String 608 | public let size: Int 609 | public let type: String 610 | public let bucket: String 611 | public let created_at: String 612 | public let created_by: String? 613 | public let folder: String? 614 | public let status: String? 615 | public let alt_text: String? 616 | public let width: Int? 617 | public let height: Int? 618 | public let url: String 619 | public let imgix_url: String? 620 | public let metadata: [String: AnyCodable]? 621 | } 622 | 623 | public struct CosmicMediaResponse: Codable { 624 | public let media: [CosmicMedia] 625 | public let total: Int 626 | public let limit: Int? 627 | public let skip: Int? 628 | } 629 | 630 | public struct CosmicMediaSingleResponse: Codable { 631 | public let message: String? 632 | public let media: CosmicMedia? 633 | 634 | private enum CodingKeys: String, CodingKey { 635 | case message, media 636 | } 637 | 638 | public init(from decoder: Decoder) throws { 639 | let container = try decoder.container(keyedBy: CodingKeys.self) 640 | message = try container.decodeIfPresent(String.self, forKey: .message) 641 | media = try container.decodeIfPresent(CosmicMedia.self, forKey: .media) 642 | } 643 | } 644 | 645 | // MARK: - Object Revision Models 646 | public struct ObjectRevision: Codable { 647 | public let id: String 648 | public let type: String 649 | public let title: String 650 | public let content: String? 651 | public let metadata: [String: AnyCodable]? 652 | public let status: String 653 | public let created_at: String 654 | public let modified_at: String 655 | } 656 | 657 | public struct ObjectRevisionsResponse: Codable { 658 | public let revisions: [ObjectRevision] 659 | public let total: Int 660 | } 661 | 662 | // MARK: - Bucket Models 663 | public struct BucketSettings: Codable { 664 | public let title: String 665 | public let description: String? 666 | public let icon: String? 667 | public let website: String? 668 | public let objects_write_key: String? 669 | public let media_write_key: String? 670 | public let deploy_hook: String? 671 | public let env: [String: String]? 672 | } 673 | 674 | public struct BucketResponse: Codable { 675 | public let bucket: BucketSettings 676 | } 677 | 678 | // MARK: - User Models 679 | public struct CosmicUser: Codable { 680 | public let id: String 681 | public let first_name: String 682 | public let last_name: String 683 | public let email: String 684 | public let role: String 685 | public let status: String 686 | public let created_at: String 687 | public let modified_at: String 688 | } 689 | 690 | public struct UsersResponse: Codable { 691 | public let users: [CosmicUser] 692 | public let total: Int 693 | } 694 | 695 | public struct UserSingleResponse: Codable { 696 | public let user: CosmicUser 697 | } 698 | 699 | // MARK: - Webhook Models 700 | public struct Webhook: Codable { 701 | public let id: String 702 | public let event: String 703 | public let endpoint: String 704 | public let created_at: String 705 | public let modified_at: String 706 | } 707 | 708 | public struct WebhooksResponse: Codable { 709 | public let webhooks: [Webhook] 710 | public let total: Int 711 | } 712 | 713 | // MARK: - Error Models 714 | public enum CosmicErrorType: String, Codable { 715 | case invalidCredentials = "INVALID_CREDENTIALS" 716 | case notFound = "NOT_FOUND" 717 | case validationError = "VALIDATION_ERROR" 718 | case rateLimitExceeded = "RATE_LIMIT_EXCEEDED" 719 | case serverError = "SERVER_ERROR" 720 | case unknown = "UNKNOWN_ERROR" 721 | } 722 | 723 | public struct CosmicErrorResponse: Codable { 724 | public let status: Int 725 | public let type: CosmicErrorType 726 | public let message: String 727 | public let details: [String: AnyCodable]? 728 | } 729 | 730 | // MARK: - AI Response Models 731 | public struct AITextUsage: Codable { 732 | public let input_tokens: Int 733 | public let output_tokens: Int 734 | } 735 | 736 | public struct AITextResponse: Codable { 737 | public let text: String 738 | public let usage: AITextUsage 739 | } 740 | 741 | public struct AIImageResponse: Codable { 742 | public let url: String 743 | } 744 | -------------------------------------------------------------------------------- /Sources/CosmicSDK/CosmicSDK.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CosmicSDK.swift 3 | // 4 | // 5 | // Created by Karl Koch on 19/09/2023. 6 | // 7 | 8 | import Foundation 9 | #if canImport(FoundationNetworking) && canImport(FoundationXML) 10 | import FoundationNetworking 11 | import FoundationXML 12 | #endif 13 | 14 | public enum CosmicError: Error { 15 | case genericError(error: Error) 16 | case decodingError(error: Error) 17 | } 18 | 19 | // Type aliases to improve IDE autocomplete 20 | public typealias CosmicStatus = CosmicEndpointProvider.Status 21 | public typealias CosmicSorting = CosmicEndpointProvider.Sorting 22 | 23 | // Regex options for $options in Cosmic queries (mirrors common PCRE flags used by Cosmic) 24 | public struct CosmicRegexOptions: OptionSet { 25 | public let rawValue: Int 26 | public init(rawValue: Int) { self.rawValue = rawValue } 27 | public static let caseInsensitive = CosmicRegexOptions(rawValue: 1 << 0) // i 28 | public static let multiline = CosmicRegexOptions(rawValue: 1 << 1) // m 29 | public static let dotMatchesNewline = CosmicRegexOptions(rawValue: 1 << 2) // s 30 | public static let extended = CosmicRegexOptions(rawValue: 1 << 3) // x 31 | } 32 | 33 | // MARK: - String to Int conversion helper 34 | // For backwards compatibility, if you have String limits in your code, 35 | // you can convert them using: Int(yourStringLimit) ?? defaultValue 36 | // Example: sdk.find(type: "posts", limit: Int("10") ?? 10) 37 | 38 | /// CosmicSDKSwift provides a Swift interface to the Cosmic API. 39 | /// 40 | /// This SDK requires: 41 | /// - iOS 14.0+ / macOS 11.0+ / tvOS 14.0+ / watchOS 7.0+ 42 | /// - Swift 5.5+ 43 | /// 44 | /// These requirements are due to the use of async/await features. 45 | public class CosmicSDKSwift { 46 | fileprivate let config: Config 47 | 48 | /// Configuration object for the client 49 | public struct Config { 50 | 51 | /// Initialiser 52 | /// - Parameter session: the session to use for network requests. 53 | public init(baseURL: String, endpointPrivider: CosmicEndpointProvider, bucketSlug: String, readKey: String, writeKey: String, session: URLSession, authorizeRequest: @escaping (inout URLRequest) -> Void) { 54 | self.baseURL = baseURL 55 | self.endpointProvider = endpointPrivider 56 | self.authorizeRequest = authorizeRequest 57 | self.bucketSlug = bucketSlug 58 | self.readKey = readKey 59 | self.writeKey = writeKey 60 | self.session = session 61 | } 62 | let baseURL: String 63 | let endpointProvider: CosmicEndpointProvider 64 | let session: URLSession 65 | let authorizeRequest: (inout URLRequest) -> Void 66 | let bucketSlug: String 67 | let readKey: String 68 | let writeKey: String 69 | 70 | public static func createBucketClient(bucketSlug: String, readKey: String, writeKey: String) -> Self { 71 | .init(baseURL: "https://api.cosmicjs.com", 72 | endpointPrivider: CosmicEndpointProvider(source: .cosmic), 73 | bucketSlug: bucketSlug, 74 | readKey: readKey, 75 | writeKey: writeKey, 76 | session: .shared, 77 | authorizeRequest: { request in 78 | // Only add Authorization header for non-GET requests 79 | // GET requests use read_key in URL parameters 80 | if request.httpMethod != "GET" { 81 | request.setValue("Bearer \(writeKey)", forHTTPHeaderField: "Authorization") 82 | } 83 | }) 84 | } 85 | } 86 | 87 | public init(_ config: Config) { 88 | self.config = config 89 | } 90 | 91 | private func makeRequest(request: URLRequest, completionHandler: @escaping (Result) -> Void) { 92 | let session = config.session 93 | let task = session.dataTask(with: request) { (data, response, error) in 94 | if let error = error { 95 | completionHandler(.failure(error)) 96 | } else if let data = data { 97 | completionHandler(.success(data)) 98 | } 99 | } 100 | 101 | task.resume() 102 | } 103 | 104 | private func prepareRequest(_ endpoint: CosmicEndpointProvider.API, body: BodyType? = nil, id: String? = nil, bucket: String, type: String, read_key: String, write_key: String? = nil, props: String? = nil, limit: String? = nil, skip: String? = nil, title: String? = nil, slug: String? = nil, content: String? = nil, metadata: [String: AnyCodable]? = nil, sort: CosmicEndpointProvider.Sorting? = nil, status: CosmicEndpointProvider.Status? = nil, depth: String? = nil, query: String? = nil) -> URLRequest where BodyType: Encodable { 105 | let pathAndParameters = config.endpointProvider.getPath(api: endpoint, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit, skip: skip, status: status, sort: sort, depth: depth, metadata: metadata, queryJSON: query) 106 | 107 | // Create URL components based on whether we have a full URL or just a path 108 | let urlComponents: URLComponents 109 | if pathAndParameters.0.starts(with: "http") { 110 | urlComponents = URLComponents(string: pathAndParameters.0)! 111 | } else { 112 | let baseURL = URL(string: config.baseURL)! 113 | var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! 114 | components.path = pathAndParameters.0 115 | urlComponents = components 116 | } 117 | 118 | // Add query parameters - only include those with actual values 119 | var finalComponents = urlComponents 120 | finalComponents.queryItems = pathAndParameters.1.compactMap { key, value in 121 | // Skip empty, nil, or whitespace-only values 122 | guard let value = value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 123 | return nil 124 | } 125 | return URLQueryItem(name: key, value: value) 126 | } 127 | 128 | var request = URLRequest(url: finalComponents.url!) 129 | request.httpMethod = config.endpointProvider.getMethod(api: endpoint) 130 | 131 | config.authorizeRequest(&request) 132 | 133 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 134 | 135 | if let body = body { 136 | if let jsonData = try? JSONEncoder().encode(body) { 137 | request.httpBody = jsonData 138 | } 139 | } 140 | 141 | return request 142 | } 143 | 144 | // Helper method for requests without body 145 | private func prepareRequest(_ endpoint: CosmicEndpointProvider.API, id: String? = nil, bucket: String, type: String, read_key: String, write_key: String? = nil, props: String? = nil, limit: String? = nil, skip: String? = nil, title: String? = nil, slug: String? = nil, content: String? = nil, metadata: [String: AnyCodable]? = nil, sort: CosmicEndpointProvider.Sorting? = nil, status: CosmicEndpointProvider.Status? = nil, depth: String? = nil, query: String? = nil) -> URLRequest { 146 | return prepareRequest(endpoint, body: nil as String?, id: id, bucket: bucket, type: type, read_key: read_key, write_key: write_key, props: props, limit: limit, skip: skip, title: title, slug: slug, content: content, metadata: metadata, sort: sort, status: status, depth: depth, query: query) 147 | } 148 | 149 | private func mimeType(for url: URL) -> String { 150 | let pathExtension = url.pathExtension.lowercased() 151 | 152 | switch pathExtension { 153 | case "jpg", "jpeg": 154 | return "image/jpeg" 155 | case "png": 156 | return "image/png" 157 | case "gif": 158 | return "image/gif" 159 | case "webp": 160 | return "image/webp" 161 | case "svg": 162 | return "image/svg+xml" 163 | case "pdf": 164 | return "application/pdf" 165 | case "doc": 166 | return "application/msword" 167 | case "docx": 168 | return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" 169 | case "xls": 170 | return "application/vnd.ms-excel" 171 | case "xlsx": 172 | return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 173 | default: 174 | return "application/octet-stream" 175 | } 176 | } 177 | } 178 | 179 | // MARK: - Internal Helpers (Query Building) 180 | extension CosmicSDKSwift { 181 | private func regexOptionsString(_ options: CosmicRegexOptions) -> String { 182 | var flags = "" 183 | if options.contains(.caseInsensitive) { flags.append("i") } 184 | if options.contains(.multiline) { flags.append("m") } 185 | if options.contains(.dotMatchesNewline) { flags.append("s") } 186 | if options.contains(.extended) { flags.append("x") } 187 | return flags 188 | } 189 | 190 | private func buildRegexQueryJSON(type: String, field: String, pattern: String, options: CosmicRegexOptions?) -> String? { 191 | var payload: [String: Any] = [ 192 | "type": type 193 | ] 194 | var fieldQuery: [String: Any] = [ 195 | "$regex": pattern 196 | ] 197 | if let options = options, !options.isEmpty { 198 | let flags = regexOptionsString(options) 199 | if !flags.isEmpty { 200 | fieldQuery["$options"] = flags 201 | } 202 | } 203 | payload[field] = fieldQuery 204 | guard let data = try? JSONSerialization.data(withJSONObject: payload), 205 | let json = String(data: data, encoding: .utf8) else { 206 | return nil 207 | } 208 | return json 209 | } 210 | 211 | /// Build MongoDB-style query JSON from a dictionary of filters 212 | /// - Parameters: 213 | /// - type: Object type to query 214 | /// - query: Dictionary of field filters with MongoDB operators 215 | /// - Returns: JSON string for the query parameter 216 | private func buildQueryJSON(type: String, query: [String: Any]) -> String? { 217 | var payload: [String: Any] = [ 218 | "type": type 219 | ] 220 | // Merge the query filters into the payload 221 | for (key, value) in query { 222 | payload[key] = value 223 | } 224 | guard let data = try? JSONSerialization.data(withJSONObject: payload), 225 | let json = String(data: data, encoding: .utf8) else { 226 | return nil 227 | } 228 | return json 229 | } 230 | } 231 | 232 | extension CosmicSDKSwift { 233 | struct Body: Encodable { 234 | let type: String? 235 | let title: String? 236 | let content: String? 237 | let metadata: [String: AnyCodable]? 238 | let status: String? 239 | let publish_at: String? 240 | let unpublish_at: String? 241 | } 242 | 243 | public struct SuccessResponse: Decodable { 244 | public let message: String? 245 | } 246 | 247 | /// Get bucket information including available object types 248 | public func getBucketInfo(completionHandler: @escaping (Result) -> Void) { 249 | let endpoint = CosmicEndpointProvider.API.getBucket 250 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 251 | 252 | makeRequest(request: request) { result in 253 | switch result { 254 | case .success(let data): 255 | do { 256 | let response = try JSONDecoder().decode(BucketResponse.self, from: data) 257 | completionHandler(.success(response)) 258 | } catch { 259 | completionHandler(.failure(.decodingError(error: error))) 260 | } 261 | case .failure(let error): 262 | completionHandler(.failure(.genericError(error: error))) 263 | } 264 | } 265 | } 266 | 267 | /// Test connection to Cosmic API 268 | public func testConnection(completionHandler: @escaping (Result) -> Void) { 269 | let endpoint = CosmicEndpointProvider.API.getBucket 270 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 271 | 272 | makeRequest(request: request) { result in 273 | switch result { 274 | case .success(let data): 275 | if let responseString = String(data: data, encoding: .utf8) { 276 | completionHandler(.success(responseString)) 277 | } else { 278 | completionHandler(.failure(.genericError(error: NSError(domain: "CosmicSDK", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response encoding"])))) 279 | } 280 | case .failure(let error): 281 | completionHandler(.failure(.genericError(error: error))) 282 | } 283 | } 284 | } 285 | 286 | public func find(type: String, query: [String: Any]? = nil, props: String? = nil, limit: Int? = nil, skip: Int? = nil, sort: CosmicSorting? = nil, status: CosmicStatus? = nil, depth: Int? = 1, completionHandler: @escaping (Result) -> Void) { 287 | let endpoint = CosmicEndpointProvider.API.find 288 | let queryJSON = query.flatMap { buildQueryJSON(type: type, query: $0) } 289 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: type, read_key: config.readKey, props: props, limit: limit?.description, skip: skip?.description, sort: sort, status: status, depth: depth?.description, query: queryJSON) 290 | 291 | makeRequest(request: request) { result in 292 | switch result { 293 | case .success(let success): 294 | do { 295 | let res = try JSONDecoder().decode(CosmicSDK.self, from: success) 296 | completionHandler(.success(res)) 297 | } catch { 298 | completionHandler(.failure(.decodingError(error: error))) 299 | } 300 | case .failure(let failure): 301 | completionHandler(.failure(.genericError(error: failure))) 302 | } 303 | } 304 | } 305 | 306 | 307 | 308 | public func findOne(type: String, id: String, props: String? = nil, limit: Int? = nil, status: CosmicStatus? = nil, depth: Int? = 1, completionHandler: @escaping (Result) -> Void) { 309 | let endpoint = CosmicEndpointProvider.API.findOne 310 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, props: props, status: status, depth: depth?.description) 311 | 312 | makeRequest(request: request) { result in 313 | switch result { 314 | case .success(let success): 315 | do { 316 | let res = try JSONDecoder().decode(CosmicSDKSingle.self, from: success) 317 | completionHandler(.success(res)) 318 | } catch { 319 | completionHandler(.failure(.decodingError(error: error))) 320 | } 321 | case .failure(let failure): 322 | completionHandler(.failure(.genericError(error: failure))) 323 | } 324 | } 325 | } 326 | 327 | 328 | 329 | public func insertOne(type: String, props: String? = nil, limit: Int? = nil, title: String, slug: String? = nil, content: String? = nil, metadata: [String: Any]? = nil, status: CosmicStatus? = nil, publish_at: String? = nil, unpublish_at: String? = nil, completionHandler: @escaping (Result) -> Void) { 330 | let endpoint = CosmicEndpointProvider.API.insertOne 331 | let metadataCodable = metadata.map { $0.mapValues { AnyCodable(value: $0) } } 332 | 333 | // If publish_at or unpublish_at is set, force status to draft 334 | let finalStatus = (publish_at != nil || unpublish_at != nil) ? "draft" : status?.rawValue 335 | 336 | let body = Body(type: type.isEmpty ? nil : type, title: title.isEmpty ? nil : title, content: content?.isEmpty == true ? nil : content, metadata: metadataCodable, status: finalStatus, publish_at: publish_at, unpublish_at: unpublish_at) 337 | let request = prepareRequest(endpoint, body: body, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit?.description, title: title, slug: slug, content: content, metadata: metadataCodable, status: status) 338 | 339 | makeRequest(request: request) { result in 340 | switch result { 341 | case .success(let success): 342 | do { 343 | let res = try JSONDecoder().decode(SuccessResponse.self, from: success) 344 | completionHandler(.success(res)) 345 | } catch { 346 | completionHandler(.failure(.decodingError(error: error))) 347 | } 348 | case .failure(let failure): 349 | completionHandler(.failure(.genericError(error: failure))) 350 | } 351 | } 352 | } 353 | 354 | /// Insert a new object and return the created object with its ID (completion handler version) 355 | /// Use this when you need access to the created object's properties (id, slug, etc.) 356 | public func insertOneWithResponse(type: String, props: String? = nil, limit: Int? = nil, title: String, slug: String? = nil, content: String? = nil, metadata: [String: Any]? = nil, status: CosmicStatus? = nil, publish_at: String? = nil, unpublish_at: String? = nil, completionHandler: @escaping (Result) -> Void) { 357 | let endpoint = CosmicEndpointProvider.API.insertOne 358 | let metadataCodable = metadata.map { $0.mapValues { AnyCodable(value: $0) } } 359 | 360 | // If publish_at or unpublish_at is set, force status to draft 361 | let finalStatus = (publish_at != nil || unpublish_at != nil) ? "draft" : status?.rawValue 362 | 363 | let body = Body(type: type.isEmpty ? nil : type, title: title.isEmpty ? nil : title, content: content?.isEmpty == true ? nil : content, metadata: metadataCodable, status: finalStatus, publish_at: publish_at, unpublish_at: unpublish_at) 364 | let request = prepareRequest(endpoint, body: body, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit?.description, title: title, slug: slug, content: content, metadata: metadataCodable, status: status) 365 | 366 | makeRequest(request: request) { result in 367 | switch result { 368 | case .success(let success): 369 | do { 370 | let res = try JSONDecoder().decode(InsertOneResponse.self, from: success) 371 | completionHandler(.success(res)) 372 | } catch { 373 | completionHandler(.failure(.decodingError(error: error))) 374 | } 375 | case .failure(let failure): 376 | completionHandler(.failure(.genericError(error: failure))) 377 | } 378 | } 379 | } 380 | 381 | 382 | 383 | public func updateOne(type: String, id: String, props: String? = nil, limit: Int? = nil, title: String? = nil, slug: String? = nil, content: String? = nil, metadata: [String: Any]? = nil, status: CosmicEndpointProvider.Status? = nil, publish_at: String? = nil, unpublish_at: String? = nil, completionHandler: @escaping (Result) -> Void) { 384 | let endpoint = CosmicEndpointProvider.API.updateOne 385 | let metadataCodable = metadata.map { $0.mapValues { AnyCodable(value: $0) } } 386 | 387 | // If publish_at or unpublish_at is set, force status to draft 388 | let finalStatus = (publish_at != nil || unpublish_at != nil) ? "draft" : status?.rawValue 389 | 390 | let body = Body(type: type.isEmpty ? nil : type, title: title, content: content, metadata: metadataCodable, status: finalStatus, publish_at: publish_at, unpublish_at: unpublish_at) 391 | let request = prepareRequest(endpoint, body: body, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit?.description, title: title, slug: slug, content: content, metadata: metadataCodable, status: status) 392 | 393 | makeRequest(request: request) { result in 394 | switch result { 395 | case .success(let success): 396 | do { 397 | let res = try JSONDecoder().decode(SuccessResponse.self, from: success) 398 | completionHandler(.success(res)) 399 | } catch { 400 | completionHandler(.failure(.decodingError(error: error))) 401 | } 402 | case .failure(let failure): 403 | completionHandler(.failure(.genericError(error: failure))) 404 | } 405 | } 406 | } 407 | 408 | 409 | 410 | public func deleteOne(type: String, id: String, completionHandler: @escaping (Result) -> Void) { 411 | let endpoint = CosmicEndpointProvider.API.deleteOne 412 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey) 413 | 414 | makeRequest(request: request) { result in 415 | switch result { 416 | case .success(let success): 417 | do { 418 | let res = try JSONDecoder().decode(SuccessResponse.self, from: success) 419 | completionHandler(.success(res)) 420 | } catch { 421 | completionHandler(.failure(.decodingError(error: error))) 422 | } 423 | case .failure(let failure): 424 | completionHandler(.failure(.genericError(error: failure))) 425 | } 426 | } 427 | } 428 | } 429 | 430 | // MARK: - Media Operations 431 | extension CosmicSDKSwift { 432 | public func uploadMedia(fileURL: URL, folder: String? = nil, metadata: [String: Any]? = nil) async throws -> CosmicMediaSingleResponse { 433 | let endpoint = CosmicEndpointProvider.API.uploadMedia(config.bucketSlug) 434 | 435 | // Create multipart form data 436 | let boundary = UUID().uuidString 437 | var request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 438 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") 439 | 440 | var data = Data() 441 | 442 | // Add file data 443 | let fileData = try Data(contentsOf: fileURL) 444 | data.append("--\(boundary)\r\n".data(using: .utf8)!) 445 | data.append("Content-Disposition: form-data; name=\"media\"; filename=\"\(fileURL.lastPathComponent)\"\r\n".data(using: .utf8)!) 446 | data.append("Content-Type: \(mimeType(for: fileURL))\r\n\r\n".data(using: .utf8)!) 447 | data.append(fileData) 448 | data.append("\r\n".data(using: .utf8)!) 449 | 450 | // Add folder if provided 451 | if let folder = folder { 452 | data.append("--\(boundary)\r\n".data(using: .utf8)!) 453 | data.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!) 454 | data.append("\(folder)\r\n".data(using: .utf8)!) 455 | } 456 | 457 | // Add metadata if provided 458 | if let metadata = metadata { 459 | data.append("--\(boundary)\r\n".data(using: .utf8)!) 460 | data.append("Content-Disposition: form-data; name=\"metadata\"\r\n".data(using: .utf8)!) 461 | data.append("Content-Type: application/json\r\n\r\n".data(using: .utf8)!) 462 | let metadataData = try JSONSerialization.data(withJSONObject: metadata) 463 | data.append(metadataData) 464 | data.append("\r\n".data(using: .utf8)!) 465 | } 466 | 467 | data.append("--\(boundary)--\r\n".data(using: .utf8)!) 468 | request.httpBody = data 469 | 470 | return try await withCheckedThrowingContinuation { continuation in 471 | makeRequest(request: request) { result in 472 | switch result { 473 | case .success(let data): 474 | do { 475 | let response = try JSONDecoder().decode(CosmicMediaSingleResponse.self, from: data) 476 | continuation.resume(returning: response) 477 | } catch { 478 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 479 | } 480 | case .failure(let error): 481 | continuation.resume(throwing: CosmicError.genericError(error: error)) 482 | } 483 | } 484 | } 485 | } 486 | 487 | public func getMedia(limit: Int? = nil, skip: Int? = nil, props: String? = nil) async throws -> CosmicMediaResponse { 488 | let endpoint = CosmicEndpointProvider.API.getMedia 489 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey, props: props, limit: limit?.description) 490 | 491 | return try await withCheckedThrowingContinuation { continuation in 492 | makeRequest(request: request) { result in 493 | switch result { 494 | case .success(let data): 495 | do { 496 | let response = try JSONDecoder().decode(CosmicMediaResponse.self, from: data) 497 | continuation.resume(returning: response) 498 | } catch { 499 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 500 | } 501 | case .failure(let error): 502 | continuation.resume(throwing: CosmicError.genericError(error: error)) 503 | } 504 | } 505 | } 506 | } 507 | 508 | 509 | 510 | public func getMediaObject(id: String) async throws -> CosmicMediaSingleResponse { 511 | let endpoint = CosmicEndpointProvider.API.getMediaObject 512 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: "", read_key: config.readKey) 513 | 514 | return try await withCheckedThrowingContinuation { continuation in 515 | makeRequest(request: request) { result in 516 | switch result { 517 | case .success(let data): 518 | do { 519 | let response = try JSONDecoder().decode(CosmicMediaSingleResponse.self, from: data) 520 | continuation.resume(returning: response) 521 | } catch { 522 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 523 | } 524 | case .failure(let error): 525 | continuation.resume(throwing: CosmicError.genericError(error: error)) 526 | } 527 | } 528 | } 529 | } 530 | 531 | public func deleteMedia(id: String) async throws -> SuccessResponse { 532 | let endpoint = CosmicEndpointProvider.API.deleteMedia 533 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 534 | 535 | return try await withCheckedThrowingContinuation { continuation in 536 | makeRequest(request: request) { result in 537 | switch result { 538 | case .success(let data): 539 | do { 540 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 541 | continuation.resume(returning: response) 542 | } catch { 543 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 544 | } 545 | case .failure(let error): 546 | continuation.resume(throwing: CosmicError.genericError(error: error)) 547 | } 548 | } 549 | } 550 | } 551 | } 552 | 553 | // MARK: - Media Operations (Completion Handlers) 554 | extension CosmicSDKSwift { 555 | public func uploadMedia(fileURL: URL, folder: String? = nil, metadata: [String: Any]? = nil, completionHandler: @escaping (Result) -> Void) { 556 | Task { 557 | do { 558 | let result = try await uploadMedia(fileURL: fileURL, folder: folder, metadata: metadata) 559 | completionHandler(.success(result)) 560 | } catch { 561 | completionHandler(.failure(error as! CosmicError)) 562 | } 563 | } 564 | } 565 | 566 | public func getMedia(limit: Int? = nil, skip: Int? = nil, props: String? = nil, completionHandler: @escaping (Result) -> Void) { 567 | Task { 568 | do { 569 | let result = try await getMedia(limit: limit, skip: skip, props: props) 570 | completionHandler(.success(result)) 571 | } catch { 572 | completionHandler(.failure(error as! CosmicError)) 573 | } 574 | } 575 | } 576 | 577 | 578 | 579 | public func getMediaObject(id: String, completionHandler: @escaping (Result) -> Void) { 580 | Task { 581 | do { 582 | let result = try await getMediaObject(id: id) 583 | completionHandler(.success(result)) 584 | } catch { 585 | completionHandler(.failure(error as! CosmicError)) 586 | } 587 | } 588 | } 589 | 590 | public func deleteMedia(id: String, completionHandler: @escaping (Result) -> Void) { 591 | Task { 592 | do { 593 | let result = try await deleteMedia(id: id) 594 | completionHandler(.success(result)) 595 | } catch { 596 | completionHandler(.failure(error as! CosmicError)) 597 | } 598 | } 599 | } 600 | } 601 | 602 | // MARK: - Connection Testing 603 | extension CosmicSDKSwift { 604 | public func getBucketInfo() async throws -> BucketResponse { 605 | let endpoint = CosmicEndpointProvider.API.getBucket 606 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 607 | 608 | return try await withCheckedThrowingContinuation { continuation in 609 | makeRequest(request: request) { result in 610 | switch result { 611 | case .success(let data): 612 | do { 613 | let response = try JSONDecoder().decode(BucketResponse.self, from: data) 614 | continuation.resume(returning: response) 615 | } catch { 616 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 617 | } 618 | case .failure(let error): 619 | continuation.resume(throwing: CosmicError.genericError(error: error)) 620 | } 621 | } 622 | } 623 | } 624 | 625 | public func testConnection() async throws -> String { 626 | let endpoint = CosmicEndpointProvider.API.getBucket 627 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 628 | 629 | return try await withCheckedThrowingContinuation { continuation in 630 | makeRequest(request: request) { result in 631 | switch result { 632 | case .success(let data): 633 | if let responseString = String(data: data, encoding: .utf8) { 634 | continuation.resume(returning: responseString) 635 | } else { 636 | continuation.resume(throwing: CosmicError.genericError(error: NSError(domain: "CosmicSDK", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response encoding"]))) 637 | } 638 | case .failure(let error): 639 | continuation.resume(throwing: CosmicError.genericError(error: error)) 640 | } 641 | } 642 | } 643 | } 644 | } 645 | 646 | // MARK: - Object Operations (Async/Await) 647 | extension CosmicSDKSwift { 648 | public func find(type: String, query: [String: Any]? = nil, props: String? = nil, limit: Int? = nil, skip: Int? = nil, sort: CosmicSorting? = nil, status: CosmicStatus? = nil, depth: Int? = 1) async throws -> CosmicSDK { 649 | let endpoint = CosmicEndpointProvider.API.find 650 | let queryJSON = query.flatMap { buildQueryJSON(type: type, query: $0) } 651 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: type, read_key: config.readKey, props: props, limit: limit?.description, skip: skip?.description, sort: sort, status: status, depth: depth?.description, query: queryJSON) 652 | 653 | return try await withCheckedThrowingContinuation { continuation in 654 | makeRequest(request: request) { result in 655 | switch result { 656 | case .success(let data): 657 | do { 658 | let response = try JSONDecoder().decode(CosmicSDK.self, from: data) 659 | continuation.resume(returning: response) 660 | } catch { 661 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 662 | } 663 | case .failure(let error): 664 | continuation.resume(throwing: CosmicError.genericError(error: error)) 665 | } 666 | } 667 | } 668 | } 669 | 670 | public func findRegex(type: String, field: String, pattern: String, options: CosmicRegexOptions = [.caseInsensitive], props: String? = nil, limit: Int? = nil, skip: Int? = nil, sort: CosmicSorting? = nil, status: CosmicStatus? = nil, depth: Int? = 1) async throws -> CosmicSDK { 671 | let endpoint = CosmicEndpointProvider.API.find 672 | let queryJSON = buildRegexQueryJSON(type: type, field: field, pattern: pattern, options: options) 673 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: type, read_key: config.readKey, props: props, limit: limit?.description, skip: skip?.description, sort: sort, status: status, depth: depth?.description, query: queryJSON) 674 | 675 | return try await withCheckedThrowingContinuation { continuation in 676 | makeRequest(request: request) { result in 677 | switch result { 678 | case .success(let data): 679 | do { 680 | let response = try JSONDecoder().decode(CosmicSDK.self, from: data) 681 | continuation.resume(returning: response) 682 | } catch { 683 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 684 | } 685 | case .failure(let error): 686 | continuation.resume(throwing: CosmicError.genericError(error: error)) 687 | } 688 | } 689 | } 690 | } 691 | 692 | public func findOne(type: String, id: String, props: String? = nil, limit: Int? = nil, status: CosmicStatus? = nil, depth: Int? = 1) async throws -> CosmicSDKSingle { 693 | let endpoint = CosmicEndpointProvider.API.findOne 694 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, props: props, status: status, depth: depth?.description) 695 | 696 | return try await withCheckedThrowingContinuation { continuation in 697 | makeRequest(request: request) { result in 698 | switch result { 699 | case .success(let data): 700 | do { 701 | let response = try JSONDecoder().decode(CosmicSDKSingle.self, from: data) 702 | continuation.resume(returning: response) 703 | } catch { 704 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 705 | } 706 | case .failure(let error): 707 | continuation.resume(throwing: CosmicError.genericError(error: error)) 708 | } 709 | } 710 | } 711 | } 712 | 713 | public func insertOne(type: String, props: String? = nil, limit: Int? = nil, title: String, slug: String? = nil, content: String? = nil, metadata: [String: Any]? = nil, status: CosmicStatus? = nil, publish_at: String? = nil, unpublish_at: String? = nil) async throws -> SuccessResponse { 714 | let endpoint = CosmicEndpointProvider.API.insertOne 715 | let metadataCodable = metadata.map { $0.mapValues { AnyCodable(value: $0) } } 716 | 717 | // If publish_at or unpublish_at is set, force status to draft 718 | let finalStatus = (publish_at != nil || unpublish_at != nil) ? "draft" : status?.rawValue 719 | 720 | let body = Body(type: type.isEmpty ? nil : type, title: title.isEmpty ? nil : title, content: content?.isEmpty == true ? nil : content, metadata: metadataCodable, status: finalStatus, publish_at: publish_at, unpublish_at: unpublish_at) 721 | let request = prepareRequest(endpoint, body: body, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit?.description, title: title, slug: slug, content: content, metadata: metadataCodable, status: status) 722 | 723 | return try await withCheckedThrowingContinuation { continuation in 724 | makeRequest(request: request) { result in 725 | switch result { 726 | case .success(let data): 727 | do { 728 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 729 | continuation.resume(returning: response) 730 | } catch { 731 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 732 | } 733 | case .failure(let error): 734 | continuation.resume(throwing: CosmicError.genericError(error: error)) 735 | } 736 | } 737 | } 738 | } 739 | 740 | /// Insert a new object and return the created object with its ID 741 | /// Use this when you need access to the created object's properties (id, slug, etc.) 742 | public func insertOneWithResponse(type: String, props: String? = nil, limit: Int? = nil, title: String, slug: String? = nil, content: String? = nil, metadata: [String: Any]? = nil, status: CosmicStatus? = nil, publish_at: String? = nil, unpublish_at: String? = nil) async throws -> InsertOneResponse { 743 | let endpoint = CosmicEndpointProvider.API.insertOne 744 | let metadataCodable = metadata.map { $0.mapValues { AnyCodable(value: $0) } } 745 | 746 | // If publish_at or unpublish_at is set, force status to draft 747 | let finalStatus = (publish_at != nil || unpublish_at != nil) ? "draft" : status?.rawValue 748 | 749 | let body = Body(type: type.isEmpty ? nil : type, title: title.isEmpty ? nil : title, content: content?.isEmpty == true ? nil : content, metadata: metadataCodable, status: finalStatus, publish_at: publish_at, unpublish_at: unpublish_at) 750 | let request = prepareRequest(endpoint, body: body, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit?.description, title: title, slug: slug, content: content, metadata: metadataCodable, status: status) 751 | 752 | return try await withCheckedThrowingContinuation { continuation in 753 | makeRequest(request: request) { result in 754 | switch result { 755 | case .success(let data): 756 | do { 757 | let response = try JSONDecoder().decode(InsertOneResponse.self, from: data) 758 | continuation.resume(returning: response) 759 | } catch { 760 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 761 | } 762 | case .failure(let error): 763 | continuation.resume(throwing: CosmicError.genericError(error: error)) 764 | } 765 | } 766 | } 767 | } 768 | 769 | public func updateOne(type: String, id: String, props: String? = nil, limit: Int? = nil, title: String? = nil, slug: String? = nil, content: String? = nil, metadata: [String: Any]? = nil, status: CosmicStatus? = nil, publish_at: String? = nil, unpublish_at: String? = nil) async throws -> SuccessResponse { 770 | let endpoint = CosmicEndpointProvider.API.updateOne 771 | let metadataCodable = metadata.map { $0.mapValues { AnyCodable(value: $0) } } 772 | 773 | // If publish_at or unpublish_at is set, force status to draft 774 | let finalStatus = (publish_at != nil || unpublish_at != nil) ? "draft" : status?.rawValue 775 | 776 | let body = Body(type: type.isEmpty ? nil : type, title: title, content: content, metadata: metadataCodable, status: finalStatus, publish_at: publish_at, unpublish_at: unpublish_at) 777 | let request = prepareRequest(endpoint, body: body, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey, props: props, limit: limit?.description, title: title, slug: slug, content: content, metadata: metadataCodable, status: status) 778 | 779 | return try await withCheckedThrowingContinuation { continuation in 780 | makeRequest(request: request) { result in 781 | switch result { 782 | case .success(let data): 783 | do { 784 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 785 | continuation.resume(returning: response) 786 | } catch { 787 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 788 | } 789 | case .failure(let error): 790 | continuation.resume(throwing: CosmicError.genericError(error: error)) 791 | } 792 | } 793 | } 794 | } 795 | 796 | public func deleteOne(type: String, id: String) async throws -> SuccessResponse { 797 | let endpoint = CosmicEndpointProvider.API.deleteOne 798 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: type, read_key: config.readKey, write_key: config.writeKey) 799 | 800 | return try await withCheckedThrowingContinuation { continuation in 801 | makeRequest(request: request) { result in 802 | switch result { 803 | case .success(let data): 804 | do { 805 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 806 | continuation.resume(returning: response) 807 | } catch { 808 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 809 | } 810 | case .failure(let error): 811 | continuation.resume(throwing: CosmicError.genericError(error: error)) 812 | } 813 | } 814 | } 815 | } 816 | } 817 | 818 | // MARK: - Object Operations (Additional) 819 | extension CosmicSDKSwift { 820 | public func getObjectRevisions(id: String) async throws -> ObjectRevisionsResponse { 821 | let endpoint = CosmicEndpointProvider.API.getObjectRevisions 822 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: "", read_key: config.readKey) 823 | 824 | return try await withCheckedThrowingContinuation { continuation in 825 | makeRequest(request: request) { result in 826 | switch result { 827 | case .success(let data): 828 | do { 829 | let response = try JSONDecoder().decode(ObjectRevisionsResponse.self, from: data) 830 | continuation.resume(returning: response) 831 | } catch { 832 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 833 | } 834 | case .failure(let error): 835 | continuation.resume(throwing: CosmicError.genericError(error: error)) 836 | } 837 | } 838 | } 839 | } 840 | 841 | public func searchObjects(query: String) async throws -> CosmicSDK { 842 | let endpoint = CosmicEndpointProvider.API.searchObjects 843 | let searchBody = ["query": query] as [String: String] 844 | let request = prepareRequest(endpoint, body: searchBody, bucket: config.bucketSlug, type: "", read_key: config.readKey) 845 | 846 | return try await withCheckedThrowingContinuation { continuation in 847 | makeRequest(request: request) { result in 848 | switch result { 849 | case .success(let data): 850 | do { 851 | let response = try JSONDecoder().decode(CosmicSDK.self, from: data) 852 | continuation.resume(returning: response) 853 | } catch { 854 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 855 | } 856 | case .failure(let error): 857 | continuation.resume(throwing: CosmicError.genericError(error: error)) 858 | } 859 | } 860 | } 861 | } 862 | } 863 | 864 | // MARK: - Object Operations (Completion Handlers) 865 | extension CosmicSDKSwift { 866 | public func getObjectRevisions(id: String, completionHandler: @escaping (Result) -> Void) { 867 | Task { 868 | do { 869 | let result = try await getObjectRevisions(id: id) 870 | completionHandler(.success(result)) 871 | } catch { 872 | completionHandler(.failure(error as! CosmicError)) 873 | } 874 | } 875 | } 876 | 877 | public func findRegex(type: String, field: String, pattern: String, options: CosmicRegexOptions = [.caseInsensitive], props: String? = nil, limit: Int? = nil, skip: Int? = nil, sort: CosmicSorting? = nil, status: CosmicStatus? = nil, depth: Int? = 1, completionHandler: @escaping (Result) -> Void) { 878 | Task { 879 | do { 880 | let result = try await findRegex(type: type, field: field, pattern: pattern, options: options, props: props, limit: limit, skip: skip, sort: sort, status: status, depth: depth) 881 | completionHandler(.success(result)) 882 | } catch { 883 | completionHandler(.failure(error as! CosmicError)) 884 | } 885 | } 886 | } 887 | 888 | public func searchObjects(query: String, completionHandler: @escaping (Result) -> Void) { 889 | Task { 890 | do { 891 | let result = try await searchObjects(query: query) 892 | completionHandler(.success(result)) 893 | } catch { 894 | completionHandler(.failure(error as! CosmicError)) 895 | } 896 | } 897 | } 898 | } 899 | 900 | // MARK: - Bucket Operations 901 | extension CosmicSDKSwift { 902 | public func getBucket() async throws -> BucketResponse { 903 | let endpoint = CosmicEndpointProvider.API.getBucket 904 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 905 | 906 | return try await withCheckedThrowingContinuation { continuation in 907 | makeRequest(request: request) { result in 908 | switch result { 909 | case .success(let data): 910 | do { 911 | let response = try JSONDecoder().decode(BucketResponse.self, from: data) 912 | continuation.resume(returning: response) 913 | } catch { 914 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 915 | } 916 | case .failure(let error): 917 | continuation.resume(throwing: CosmicError.genericError(error: error)) 918 | } 919 | } 920 | } 921 | } 922 | 923 | public func updateBucketSettings(settings: BucketSettings) async throws -> SuccessResponse { 924 | let endpoint = CosmicEndpointProvider.API.updateBucketSettings 925 | let request = prepareRequest(endpoint, body: settings, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 926 | 927 | return try await withCheckedThrowingContinuation { continuation in 928 | makeRequest(request: request) { result in 929 | switch result { 930 | case .success(let data): 931 | do { 932 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 933 | continuation.resume(returning: response) 934 | } catch { 935 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 936 | } 937 | case .failure(let error): 938 | continuation.resume(throwing: CosmicError.genericError(error: error)) 939 | } 940 | } 941 | } 942 | } 943 | } 944 | 945 | // MARK: - Bucket Operations (Completion Handlers) 946 | extension CosmicSDKSwift { 947 | public func getBucket(completionHandler: @escaping (Result) -> Void) { 948 | Task { 949 | do { 950 | let result = try await getBucket() 951 | completionHandler(.success(result)) 952 | } catch { 953 | completionHandler(.failure(error as! CosmicError)) 954 | } 955 | } 956 | } 957 | 958 | public func updateBucketSettings(settings: BucketSettings, completionHandler: @escaping (Result) -> Void) { 959 | Task { 960 | do { 961 | let result = try await updateBucketSettings(settings: settings) 962 | completionHandler(.success(result)) 963 | } catch { 964 | completionHandler(.failure(error as! CosmicError)) 965 | } 966 | } 967 | } 968 | } 969 | 970 | // MARK: - User Operations 971 | extension CosmicSDKSwift { 972 | public func getUsers() async throws -> UsersResponse { 973 | let endpoint = CosmicEndpointProvider.API.getUsers 974 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 975 | 976 | return try await withCheckedThrowingContinuation { continuation in 977 | makeRequest(request: request) { result in 978 | switch result { 979 | case .success(let data): 980 | do { 981 | let response = try JSONDecoder().decode(UsersResponse.self, from: data) 982 | continuation.resume(returning: response) 983 | } catch { 984 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 985 | } 986 | case .failure(let error): 987 | continuation.resume(throwing: CosmicError.genericError(error: error)) 988 | } 989 | } 990 | } 991 | } 992 | 993 | public func getUser(id: String) async throws -> UserSingleResponse { 994 | let endpoint = CosmicEndpointProvider.API.getUser 995 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: "", read_key: config.readKey) 996 | 997 | return try await withCheckedThrowingContinuation { continuation in 998 | makeRequest(request: request) { result in 999 | switch result { 1000 | case .success(let data): 1001 | do { 1002 | let response = try JSONDecoder().decode(UserSingleResponse.self, from: data) 1003 | continuation.resume(returning: response) 1004 | } catch { 1005 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1006 | } 1007 | case .failure(let error): 1008 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1009 | } 1010 | } 1011 | } 1012 | } 1013 | 1014 | public func addUser(email: String, role: String) async throws -> UserSingleResponse { 1015 | let endpoint = CosmicEndpointProvider.API.addUser 1016 | let userBody = ["email": email, "role": role] as [String: String] 1017 | let request = prepareRequest(endpoint, body: userBody, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 1018 | 1019 | return try await withCheckedThrowingContinuation { continuation in 1020 | makeRequest(request: request) { result in 1021 | switch result { 1022 | case .success(let data): 1023 | do { 1024 | let response = try JSONDecoder().decode(UserSingleResponse.self, from: data) 1025 | continuation.resume(returning: response) 1026 | } catch { 1027 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1028 | } 1029 | case .failure(let error): 1030 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1031 | } 1032 | } 1033 | } 1034 | } 1035 | 1036 | public func deleteUser(id: String) async throws -> SuccessResponse { 1037 | let endpoint = CosmicEndpointProvider.API.deleteUser 1038 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 1039 | 1040 | return try await withCheckedThrowingContinuation { continuation in 1041 | makeRequest(request: request) { result in 1042 | switch result { 1043 | case .success(let data): 1044 | do { 1045 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 1046 | continuation.resume(returning: response) 1047 | } catch { 1048 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1049 | } 1050 | case .failure(let error): 1051 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1052 | } 1053 | } 1054 | } 1055 | } 1056 | } 1057 | 1058 | // MARK: - User Operations (Completion Handlers) 1059 | extension CosmicSDKSwift { 1060 | public func getUsers(completionHandler: @escaping (Result) -> Void) { 1061 | Task { 1062 | do { 1063 | let result = try await getUsers() 1064 | completionHandler(.success(result)) 1065 | } catch { 1066 | completionHandler(.failure(error as! CosmicError)) 1067 | } 1068 | } 1069 | } 1070 | 1071 | public func getUser(id: String, completionHandler: @escaping (Result) -> Void) { 1072 | Task { 1073 | do { 1074 | let result = try await getUser(id: id) 1075 | completionHandler(.success(result)) 1076 | } catch { 1077 | completionHandler(.failure(error as! CosmicError)) 1078 | } 1079 | } 1080 | } 1081 | 1082 | public func addUser(email: String, role: String, completionHandler: @escaping (Result) -> Void) { 1083 | Task { 1084 | do { 1085 | let result = try await addUser(email: email, role: role) 1086 | completionHandler(.success(result)) 1087 | } catch { 1088 | completionHandler(.failure(error as! CosmicError)) 1089 | } 1090 | } 1091 | } 1092 | 1093 | public func deleteUser(id: String, completionHandler: @escaping (Result) -> Void) { 1094 | Task { 1095 | do { 1096 | let result = try await deleteUser(id: id) 1097 | completionHandler(.success(result)) 1098 | } catch { 1099 | completionHandler(.failure(error as! CosmicError)) 1100 | } 1101 | } 1102 | } 1103 | } 1104 | 1105 | // MARK: - Webhook Operations 1106 | extension CosmicSDKSwift { 1107 | public func getWebhooks() async throws -> WebhooksResponse { 1108 | let endpoint = CosmicEndpointProvider.API.getWebhooks 1109 | let request = prepareRequest(endpoint, bucket: config.bucketSlug, type: "", read_key: config.readKey) 1110 | 1111 | return try await withCheckedThrowingContinuation { continuation in 1112 | makeRequest(request: request) { result in 1113 | switch result { 1114 | case .success(let data): 1115 | do { 1116 | let response = try JSONDecoder().decode(WebhooksResponse.self, from: data) 1117 | continuation.resume(returning: response) 1118 | } catch { 1119 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1120 | } 1121 | case .failure(let error): 1122 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1123 | } 1124 | } 1125 | } 1126 | } 1127 | 1128 | public func addWebhook(event: String, webhookEndpoint: String) async throws -> SuccessResponse { 1129 | let endpoint = CosmicEndpointProvider.API.addWebhook 1130 | let webhookBody = ["event": event, "endpoint": webhookEndpoint] as [String: String] 1131 | let request = prepareRequest(endpoint, body: webhookBody, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 1132 | 1133 | return try await withCheckedThrowingContinuation { continuation in 1134 | makeRequest(request: request) { result in 1135 | switch result { 1136 | case .success(let data): 1137 | do { 1138 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 1139 | continuation.resume(returning: response) 1140 | } catch { 1141 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1142 | } 1143 | case .failure(let error): 1144 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1145 | } 1146 | } 1147 | } 1148 | } 1149 | 1150 | public func deleteWebhook(id: String) async throws -> SuccessResponse { 1151 | let endpoint = CosmicEndpointProvider.API.deleteWebhook 1152 | let request = prepareRequest(endpoint, id: id, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 1153 | 1154 | return try await withCheckedThrowingContinuation { continuation in 1155 | makeRequest(request: request) { result in 1156 | switch result { 1157 | case .success(let data): 1158 | do { 1159 | let response = try JSONDecoder().decode(SuccessResponse.self, from: data) 1160 | continuation.resume(returning: response) 1161 | } catch { 1162 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1163 | } 1164 | case .failure(let error): 1165 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1166 | } 1167 | } 1168 | } 1169 | } 1170 | } 1171 | 1172 | // MARK: - Webhook Operations (Completion Handlers) 1173 | extension CosmicSDKSwift { 1174 | public func getWebhooks(completionHandler: @escaping (Result) -> Void) { 1175 | Task { 1176 | do { 1177 | let result = try await getWebhooks() 1178 | completionHandler(.success(result)) 1179 | } catch { 1180 | completionHandler(.failure(error as! CosmicError)) 1181 | } 1182 | } 1183 | } 1184 | 1185 | public func addWebhook(event: String, webhookEndpoint: String, completionHandler: @escaping (Result) -> Void) { 1186 | Task { 1187 | do { 1188 | let result = try await addWebhook(event: event, webhookEndpoint: webhookEndpoint) 1189 | completionHandler(.success(result)) 1190 | } catch { 1191 | completionHandler(.failure(error as! CosmicError)) 1192 | } 1193 | } 1194 | } 1195 | 1196 | public func deleteWebhook(id: String, completionHandler: @escaping (Result) -> Void) { 1197 | Task { 1198 | do { 1199 | let result = try await deleteWebhook(id: id) 1200 | completionHandler(.success(result)) 1201 | } catch { 1202 | completionHandler(.failure(error as! CosmicError)) 1203 | } 1204 | } 1205 | } 1206 | } 1207 | 1208 | // MARK: - AI Operations 1209 | extension CosmicSDKSwift { 1210 | public func generateText(prompt: String) async throws -> AITextResponse { 1211 | let endpoint = CosmicEndpointProvider.API.generateText(config.bucketSlug) 1212 | let body = ["prompt": prompt] 1213 | let request = prepareRequest(endpoint, body: body, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 1214 | 1215 | return try await withCheckedThrowingContinuation { continuation in 1216 | makeRequest(request: request) { result in 1217 | switch result { 1218 | case .success(let data): 1219 | do { 1220 | let response = try JSONDecoder().decode(AITextResponse.self, from: data) 1221 | continuation.resume(returning: response) 1222 | } catch { 1223 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1224 | } 1225 | case .failure(let error): 1226 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1227 | } 1228 | } 1229 | } 1230 | } 1231 | 1232 | public func generateImage(prompt: String, size: String = "1024x1024", quality: String = "standard", style: String = "vivid") async throws -> AIImageResponse { 1233 | let endpoint = CosmicEndpointProvider.API.generateImage(config.bucketSlug) 1234 | let body = [ 1235 | "prompt": prompt, 1236 | "size": size, 1237 | "quality": quality, 1238 | "style": style 1239 | ] 1240 | let request = prepareRequest(endpoint, body: body, bucket: config.bucketSlug, type: "", read_key: config.readKey, write_key: config.writeKey) 1241 | 1242 | return try await withCheckedThrowingContinuation { continuation in 1243 | makeRequest(request: request) { result in 1244 | switch result { 1245 | case .success(let data): 1246 | do { 1247 | let response = try JSONDecoder().decode(AIImageResponse.self, from: data) 1248 | continuation.resume(returning: response) 1249 | } catch { 1250 | continuation.resume(throwing: CosmicError.decodingError(error: error)) 1251 | } 1252 | case .failure(let error): 1253 | continuation.resume(throwing: CosmicError.genericError(error: error)) 1254 | } 1255 | } 1256 | } 1257 | } 1258 | } 1259 | 1260 | // MARK: - AI Operations (Completion Handlers) 1261 | extension CosmicSDKSwift { 1262 | public func generateText(prompt: String, completionHandler: @escaping (Result) -> Void) { 1263 | Task { 1264 | do { 1265 | let result = try await generateText(prompt: prompt) 1266 | completionHandler(.success(result)) 1267 | } catch { 1268 | completionHandler(.failure(error as! CosmicError)) 1269 | } 1270 | } 1271 | } 1272 | 1273 | public func generateImage(prompt: String, size: String = "1024x1024", quality: String = "standard", style: String = "vivid", completionHandler: @escaping (Result) -> Void) { 1274 | Task { 1275 | do { 1276 | let result = try await generateImage(prompt: prompt, size: size, quality: quality, style: style) 1277 | completionHandler(.success(result)) 1278 | } catch { 1279 | completionHandler(.failure(error as! CosmicError)) 1280 | } 1281 | } 1282 | } 1283 | } 1284 | --------------------------------------------------------------------------------