├── .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 |
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 |
--------------------------------------------------------------------------------