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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ struct ShopifyAcceleratedCheckoutsApp: App {
@AppStorage(AppStorageKeys.email.rawValue) var email: String = ""
@AppStorage(AppStorageKeys.phone.rawValue) var phone: String = ""
@AppStorage(AppStorageKeys.supportedCountries.rawValue) var supportedCountriesString: String = ""
@StateObject private var configuration: ShopifyAcceleratedCheckouts.Configuration

var configuration: ShopifyAcceleratedCheckouts.Configuration {
.init(
storefrontDomain: EnvironmentVariables.storefrontDomain,
storefrontAccessToken: EnvironmentVariables.storefrontAccessToken,
customer: ShopifyAcceleratedCheckouts.Customer(
email: email.isEmpty ? nil : email,
phoneNumber: phone.isEmpty ? nil : phone
init() {
let email = UserDefaults.standard.string(forKey: AppStorageKeys.email.rawValue) ?? ""
let phone = UserDefaults.standard.string(forKey: AppStorageKeys.phone.rawValue) ?? ""
_configuration = StateObject(
wrappedValue: ShopifyAcceleratedCheckouts.Configuration(
storefrontDomain: EnvironmentVariables.storefrontDomain,
storefrontAccessToken: EnvironmentVariables.storefrontAccessToken,
customer: Self.customer(email: email, phone: phone)
)
)
}
Expand Down Expand Up @@ -47,19 +49,24 @@ struct ShopifyAcceleratedCheckoutsApp: App {
}
.onAppear {
ShopifyAcceleratedCheckouts.logLevel = logLevel
updateConfiguration()
}
.environmentObject(configuration)
.environmentObject(applePayConfiguration)
}
.environment(\.locale, Locale(identifier: locale))
}

private func updateConfiguration() {
configuration.customer = ShopifyAcceleratedCheckouts.Customer(
private static func customer(email: String, phone: String) -> ShopifyAcceleratedCheckouts.Customer {
ShopifyAcceleratedCheckouts.Customer(
email: email.isEmpty ? nil : email,
phoneNumber: phone.isEmpty ? nil : phone
)
}

private func updateConfiguration() {
configuration.customer = Self.customer(email: email, phone: phone)
}
}

private func createApplePayConfiguration(
Expand Down
9 changes: 5 additions & 4 deletions platforms/swift/Scripts/lint
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@ if [[ "$VERBOSE" == "false" ]]; then
QUIET_FLAG="--quiet"
fi

# Run from the directory containing .swiftlint.yml so its `included:` and
# `excluded:` paths resolve consistently.
PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"

if [[ "$MODE" == "fix" ]]; then
$SWIFTLINT lint --fix --no-cache $QUIET_FLAG
(cd "$PROJECT_ROOT" && $SWIFTLINT lint --fix --no-cache $QUIET_FLAG)

@kieran-osgood-shopify kieran-osgood-shopify Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the script appears to have been running from the wrong dir - which means the .swiftlint paths were resolving the wrong paths - I had tons of file changes as a result on generated files

fi

# SwiftLint doesn't report errors when running in fix mode
# Running again in strict mode to ensure no errors are missed.
# Run from the directory containing .swiftlint.yml so its `included:` paths
# resolve consistently.
PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
(cd "$PROJECT_ROOT" && $SWIFTLINT lint --strict --no-cache $QUIET_FLAG)
LINT_STATUS=$?

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Protocol for abstracting time-based operations to enable testing
protocol Clock {
protocol Clock: Sendable {
/// Sleep for the specified number of nanoseconds
func sleep(nanoseconds: UInt64) async throws
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func exponentialDelay(for attempt: Int = 1, with retryDelay: TimeInterval) -> UI
return UInt64(delayInNanoseconds)
}

extension Task where Failure == Error {
extension Task where Failure == Error, Success: Sendable {
@discardableResult static func retrying(
priority: TaskPriority? = nil,
maxRetryCount: Int = 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ShopifyCheckoutKit

/// A lightweight GraphQL client for the Storefront API without external dependencies
@available(iOS 16.0, *)
class GraphQLClient {
final class GraphQLClient: Sendable {
let url: URL
private let headers: [String: String]
private let session: URLSession
Expand All @@ -30,19 +30,19 @@ class GraphQLClient {
/// Execute a GraphQL query
/// - Parameter operation: The GraphQL query operation
/// - Returns: The decoded response
func query<T: Decodable>(_ operation: GraphQLRequest<T>) async throws -> GraphQLResponse<T> {
func query<T: Decodable & Sendable>(_ operation: GraphQLRequest<T>) async throws -> GraphQLResponse<T> {
return try await execute(operation: operation)
}

/// Execute a GraphQL mutation
/// - Parameter operation: The GraphQL mutation operation
/// - Returns: The decoded response
func mutate<T: Decodable>(_ operation: GraphQLRequest<T>) async throws -> GraphQLResponse<T> {
func mutate<T: Decodable & Sendable>(_ operation: GraphQLRequest<T>) async throws -> GraphQLResponse<T> {
return try await execute(operation: operation)
}

/// Execute a raw GraphQL request
private func execute<T: Decodable>(
private func execute<T: Decodable & Sendable>(
operation: GraphQLRequest<T>
) async throws -> GraphQLResponse<T> {
let urlRequest = try getURLRequest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ extension GraphQLRequest {
return GraphQLRequest(
query: lines.joined(separator: "\n"),
responseType: responseType,
variables: variables
encodedVariables: encodedVariables
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,32 @@ import Foundation

/// GraphQLOperation binds a query (data to be requested)
/// with a response Decoder(Codable to decode the data requested).
struct GraphQLRequest<T: Decodable>: Encodable {
struct GraphQLRequest<T: Decodable & Sendable>: Encodable {
private(set) var query: String
let responseType: T.Type
let variables: [String: Any]
let encodedVariables: [String: AnyCodable]

var variables: [String: Any] {
encodedVariables.mapValues { $0.value }
}

enum CodingKeys: String, CodingKey {
case query
case variables
}

init(query: String, responseType: T.Type, variables: [String: Any] = [:]) {
self.init(
query: query,
responseType: responseType,
encodedVariables: variables.mapValues(AnyCodable.init)
)
}

init(query: String, responseType: T.Type, encodedVariables: [String: AnyCodable]) {
self.query = query
self.responseType = responseType
self.variables = variables
self.encodedVariables = encodedVariables
}

init(
Expand Down Expand Up @@ -50,8 +62,8 @@ struct GraphQLRequest<T: Decodable>: Encodable {

try container.encode(query, forKey: .query)

if !variables.isEmpty {
try container.encode(AnyCodable(variables), forKey: .variables)
if !encodedVariables.isEmpty {
try container.encode(encodedVariables, forKey: .variables)
}
}

Expand Down Expand Up @@ -79,7 +91,7 @@ struct GraphQLRequest<T: Decodable>: Encodable {
return GraphQLRequest(
query: minifiedQuery,
responseType: responseType,
variables: variables
encodedVariables: encodedVariables
)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// GraphQL response structure
struct GraphQLResponse<T: Decodable>: Decodable {
struct GraphQLResponse<T: Decodable & Sendable>: Decodable {
let data: T?
let errors: [GraphQLResponseError]?
let extensions: [String: AnyCodable]?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,56 +28,122 @@ enum GraphQLError: LocalizedError {
}
}

/// Helper type for encoding/decoding Any values
private enum GraphQLJSONValue {
case bool(Bool)
case int(Int)
case double(Double)
case string(String)
case null
case array([GraphQLJSONValue])
case object([String: GraphQLJSONValue])
case unsupported(String)

init(_ value: Any) {
switch value {
case let value as Bool:
self = .bool(value)
case let value as Int:
self = .int(value)
case let value as Double:
self = .double(value)
case let value as String:
self = .string(value)
case let value as [String: Any]:
self = .object(value.mapValues(GraphQLJSONValue.init))
case let value as [Any]:
self = .array(value.map(GraphQLJSONValue.init))
case let value as AnyCodable:
self = value.storage
case is NSNull:
self = .null
default:
self = .unsupported(String(describing: type(of: value)))
}
}

var value: Any {
switch self {
case let .bool(value):
return value
case let .int(value):
return value
case let .double(value):
return value
case let .string(value):
return value
case .null:
return NSNull()
case let .array(values):
return values.map(\.value)
case let .object(values):
return values.mapValues { $0.value }
case let .unsupported(typeName):
return typeName
}
}

func encode(to container: inout SingleValueEncodingContainer) throws {
switch self {
case let .bool(value):
try container.encode(value)
case let .int(value):
try container.encode(value)
case let .double(value):
try container.encode(value)
case let .string(value):
try container.encode(value)
case let .array(values):
try container.encode(values.map(AnyCodable.init))
case let .object(values):
try container.encode(values.mapValues { AnyCodable($0) })
case .null:
try container.encodeNil()
case let .unsupported(typeName):
throw EncodingError.invalidValue(typeName, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unable to encode value of type \(typeName)"))
}
}
}

/// Helper type for encoding/decoding dynamically-shaped JSON values.
struct AnyCodable: Codable {
let value: Any
fileprivate let storage: GraphQLJSONValue

var value: Any {
storage.value
}

init(_ value: Any) {
self.value = value
storage = GraphQLJSONValue(value)
}

fileprivate init(_ storage: GraphQLJSONValue) {
self.storage = storage
}

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()

if let value = try? container.decode(Bool.self) {
self.value = value
storage = .bool(value)
} else if let value = try? container.decode(Int.self) {
self.value = value
storage = .int(value)
} else if let value = try? container.decode(Double.self) {
self.value = value
storage = .double(value)
} else if let value = try? container.decode(String.self) {
self.value = value
storage = .string(value)
} else if let value = try? container.decode([String: AnyCodable].self) {
self.value = value.mapValues { $0.value }
storage = .object(value.mapValues(\.storage))
} else if let value = try? container.decode([AnyCodable].self) {
self.value = value.map { $0.value }
storage = .array(value.map(\.storage))
} else if container.decodeNil() {
value = NSNull()
storage = .null
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unable to decode value")
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

switch value {
case let value as Bool:
try container.encode(value)
case let value as Int:
try container.encode(value)
case let value as Double:
try container.encode(value)
case let value as String:
try container.encode(value)
case let value as [String: Any]:
try container.encode(value.mapValues { AnyCodable($0) })
case let value as [Any]:
try container.encode(value.map { AnyCodable($0) })
case is NSNull:
try container.encodeNil()
default:
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unable to encode value"))
}
try storage.encode(to: &container)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ extension StorefrontAPI {
/// Get shop information
/// - Returns: Shop details
func shop() async throws -> Shop {
try await QueryCache.shared.load(
let client = client
return try await QueryCache.shared.load(
cacheKey: "shop",
url: client.url,
query: {
let response = try await self.client.query(Operations.getShop())
let response = try await client.query(Operations.getShop())
guard let shop = response.data?.shop else {
throw StorefrontAPI.Errors.payload(propertyName: "shop")
}
Expand All @@ -41,16 +42,16 @@ extension StorefrontAPI {
actor QueryCache {
static let shared = QueryCache()

private var cache: [String: Any] = [:]
private var inflightRequests: [String: Any] = [:]
private var cache: [String: any Sendable] = [:]
private var inflightRequests: [String: any Sendable] = [:]

private init() {}

/// Loads data with deduplication - multiple simultaneous calls will share the same request
func load<T>(
func load<T: Sendable>(
cacheKey: String,
url: URL,
query: @escaping () async throws -> T
query: @Sendable @escaping () async throws -> T
) async throws -> T {
let key = buildCacheKey(queryKey: cacheKey, url: url)

Expand Down Expand Up @@ -80,7 +81,7 @@ actor QueryCache {
}
}

private func cache(_ result: some Any, for key: String) {
private func cache(_ result: some Sendable, for key: String) {
cache[key] = result
}

Expand Down
Loading
Loading