Skip to content

Commit

Permalink
Merge pull request #707 from apollographql/add/upload-docs
Browse files Browse the repository at this point in the history
Mo' Better Uploading
  • Loading branch information
designatednerd authored Aug 15, 2019
2 parents 609243c + fe0d847 commit 426818a
Show file tree
Hide file tree
Showing 14 changed files with 492 additions and 154 deletions.
2 changes: 1 addition & 1 deletion Apollo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0830;
LastUpgradeCheck = 1020;
LastUpgradeCheck = 1030;
ORGANIZATIONNAME = "Apollo GraphQL";
TargetAttributes = {
9F8A95771EC0FC1200304A2D = {
Expand Down
2 changes: 1 addition & 1 deletion Apollo.xcodeproj/xcshareddata/xcschemes/Apollo.xcscheme
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1030"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1030"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1030"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1030"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
92 changes: 67 additions & 25 deletions Sources/Apollo/ApolloClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ public class ApolloClient {
private let queue: DispatchQueue
private let operationQueue: OperationQueue

public enum ApolloClientError: Error, LocalizedError {
case noUploadTransport

public var localizedDescription: String {
switch self {
case .noUploadTransport:
return "Attempting to upload using a transport which does not support uploads. This is a developer error."
}
}
}

/// Creates a client with the specified network transport and store.
///
/// - Parameters:
Expand Down Expand Up @@ -117,6 +128,30 @@ public class ApolloClient {
@discardableResult public func perform<Mutation: GraphQLMutation>(mutation: Mutation, context: UnsafeMutableRawPointer? = nil, queue: DispatchQueue = DispatchQueue.main, resultHandler: GraphQLResultHandler<Mutation.Data>? = nil) -> Cancellable {
return send(operation: mutation, shouldPublishResultToStore: true, context: context, resultHandler: wrapResultHandler(resultHandler, queue: queue))
}

/// Uploads the given files with the given operation.
///
/// - Parameters:
/// - operation: The operation to send
/// - files: An array of `GraphQLFile` objects to send.
/// - queue: A dispatch queue on which the result handler will be called. Defaults to the main queue.
/// - completionHandler: The completion handler to execute when the request completes or errors
/// - Returns: An object that can be used to cancel an in progress request.
/// - Throws: If your `networkTransport` does nto also conform to `UploadingNetworkTransport`.
@discardableResult public func upload<Operation: GraphQLOperation>(operation: Operation, context: UnsafeMutableRawPointer? = nil, files: [GraphQLFile], queue: DispatchQueue = .main, resultHandler: GraphQLResultHandler<Operation.Data>? = nil) -> Cancellable {
let wrappedHandler = wrapResultHandler(resultHandler, queue: queue)
guard let uploadingTransport = self.networkTransport as? UploadingNetworkTransport else {
assertionFailure("Trying to upload without an uploading transport. Please make sure your network transport conforms to `UploadingNetworkTransport`.")
wrappedHandler(.failure(ApolloClientError.noUploadTransport))
return EmptyCancellable()
}

return uploadingTransport.upload(operation: operation, files: files) { result in
self.handleOperationResult(shouldPublishResultToStore: true,
context: context, result,
resultHandler: wrappedHandler)
}
}

/// Subscribe to a subscription
///
Expand All @@ -132,33 +167,40 @@ public class ApolloClient {

fileprivate func send<Operation: GraphQLOperation>(operation: Operation, shouldPublishResultToStore: Bool, context: UnsafeMutableRawPointer?, resultHandler: @escaping GraphQLResultHandler<Operation.Data>) -> Cancellable {
return networkTransport.send(operation: operation) { result in
switch result {
case .failure(let error):
resultHandler(.failure(error))
case .success(let response):
// If there is no need to publish the result to the store, we can use a fast path.
if !shouldPublishResultToStore {
do {
let result = try response.parseResultFast()
resultHandler(.success(result))
} catch {
resultHandler(.failure(error))
}
return
self.handleOperationResult(shouldPublishResultToStore: shouldPublishResultToStore,
context: context,
result,
resultHandler: resultHandler)
}
}

private func handleOperationResult<Operation>(shouldPublishResultToStore: Bool, context: UnsafeMutableRawPointer?, _ result: Result<GraphQLResponse<Operation>, Error>, resultHandler: @escaping GraphQLResultHandler<Operation.Data>) {
switch result {
case .failure(let error):
resultHandler(.failure(error))
case .success(let response):
// If there is no need to publish the result to the store, we can use a fast path.
if !shouldPublishResultToStore {
do {
let result = try response.parseResultFast()
resultHandler(.success(result))
} catch {
resultHandler(.failure(error))
}

firstly {
try response.parseResult(cacheKeyForObject: self.cacheKeyForObject)
}.andThen { (result, records) in
if let records = records {
self.store.publish(records: records, context: context).catch { error in
preconditionFailure(String(describing: error))
}
return
}

firstly {
try response.parseResult(cacheKeyForObject: self.cacheKeyForObject)
}.andThen { (result, records) in
if let records = records {
self.store.publish(records: records, context: context).catch { error in
preconditionFailure(String(describing: error))
}
resultHandler(.success(result))
}.catch { error in
resultHandler(.failure(error))
}
}
resultHandler(.success(result))
}.catch { error in
resultHandler(.failure(error))
}
}
}
Expand Down
27 changes: 13 additions & 14 deletions Sources/Apollo/HTTPNetworkTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,7 @@ public class HTTPNetworkTransport {
self.useGETForQueries = useGETForQueries
self.delegate = delegate
}

/// Uploads the given files with the given operation.
///
/// - Parameters:
/// - operation: The operation to send
/// - files: An array of `GraphQLFile` objects to send.
/// - completionHandler: The completion handler to execute when the request completes or errors
/// - Returns: An object that can be used to cancel an in progress request.
public func upload<Operation>(operation: Operation, files: [GraphQLFile], completionHandler: @escaping (_ result: Result<GraphQLResponse<Operation>, Error>) -> Void) -> Cancellable {
return send(operation: operation, files: files, completionHandler: completionHandler)
}


private func send<Operation>(operation: Operation, files: [GraphQLFile]?, completionHandler: @escaping (_ results: Result<GraphQLResponse<Operation>, Error>) -> Void) -> Cancellable {
let request: URLRequest
do {
Expand Down Expand Up @@ -227,6 +216,9 @@ public class HTTPNetworkTransport {
let body = RequestCreator.requestBody(for: operation, sendOperationIdentifiers: self.sendOperationIdentifiers)
var request = URLRequest(url: self.url)

// We default to json, but this can be changed below if needed.
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

if self.useGETForQueries && operation.operationType == .query {
let transformer = GraphQLGETTransformer(body: body, url: self.url)
if let urlForGet = transformer.createGetURL() {
Expand Down Expand Up @@ -262,8 +254,6 @@ public class HTTPNetworkTransport {
request.setValue(operationID, forHTTPHeaderField: "X-APOLLO-OPERATION-ID")
}

request.setValue("application/json", forHTTPHeaderField: "Content-Type")

// If there's a delegate, do a pre-flight check and allow modifications to the request.
if
let delegate = self.delegate,
Expand All @@ -288,6 +278,15 @@ extension HTTPNetworkTransport: NetworkTransport {
}
}

// MARK: - UploadingNetworkTransport conformance

extension HTTPNetworkTransport: UploadingNetworkTransport {

public func upload<Operation>(operation: Operation, files: [GraphQLFile], completionHandler: @escaping (_ result: Result<GraphQLResponse<Operation>, Error>) -> Void) -> Cancellable {
return send(operation: operation, files: files, completionHandler: completionHandler)
}
}

// MARK: - Equatable conformance

extension HTTPNetworkTransport: Equatable {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Apollo/JSONSerializationFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

public final class JSONSerializationFormat {
public class func serialize(value: JSONEncodable) throws -> Data {
return try JSONSerialization.data(withJSONObject: value.jsonValue, options: [])
return try JSONSerialization.dataSortedIfPossible(withJSONObject: value.jsonValue)
}

public class func deserialize(data: Data) throws -> JSONValue {
Expand Down
13 changes: 13 additions & 0 deletions Sources/Apollo/NetworkTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,16 @@ public protocol NetworkTransport {
/// - Returns: An object that can be used to cancel an in progress request.
func send<Operation>(operation: Operation, completionHandler: @escaping (_ result: Result<GraphQLResponse<Operation>, Error>) -> Void) -> Cancellable
}

/// A network transport which can also handle uploads of files.
public protocol UploadingNetworkTransport: NetworkTransport {

/// Uploads the given files with the given operation.
///
/// - Parameters:
/// - operation: The operation to send
/// - files: An array of `GraphQLFile` objects to send.
/// - completionHandler: The completion handler to execute when the request completes or errors
/// - Returns: An object that can be used to cancel an in progress request.
func upload<Operation>(operation: Operation, files: [GraphQLFile], completionHandler: @escaping (_ result: Result<GraphQLResponse<Operation>, Error>) -> Void) -> Cancellable
}
75 changes: 58 additions & 17 deletions Sources/Apollo/RequestCreator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,68 @@ public struct RequestCreator {
return body
}

static func requestMultipartFormData<Operation: GraphQLOperation>(for operation: Operation, files: [GraphQLFile], sendOperationIdentifiers: Bool, serializationFormat: JSONSerializationFormat.Type) throws -> MultipartFormData {
let formData = MultipartFormData()

let fields = requestBody(for: operation, sendOperationIdentifiers: sendOperationIdentifiers)
for (name, data) in fields {
if let data = data as? GraphQLMap {
let data = try serializationFormat.serialize(value: data)
formData.appendPart(data: data, name: name)
} else if let data = data as? String {
try formData.appendPart(string: data, name: name)
/// Creates multi-part form data to send with a request
///
/// - Parameters:
/// - operation: The operation to create the data for.
/// - files: An array of files to use.
/// - sendOperationIdentifiers: True if operation identifiers should be sent, false if not.
/// - serializationFormat: The format to use to serialize data.
/// - manualBoundary: [optional] A manual boundary to pass in. A default boundary will be used otherwise.
/// - Returns: The created form data
/// - Throws: Errors creating or loading the form data
static func requestMultipartFormData<Operation: GraphQLOperation>(for operation: Operation,
files: [GraphQLFile],
sendOperationIdentifiers: Bool,
serializationFormat: JSONSerializationFormat.Type,
manualBoundary: String? = nil) throws -> MultipartFormData {
let formData: MultipartFormData

if let boundary = manualBoundary {
formData = MultipartFormData(boundary: boundary)
} else {
formData = MultipartFormData()
}

// Make sure all fields for files are set to null, or the server won't look
// for the files in the rest of the form data
let fieldsForFiles = Set(files.map { $0.fieldName })
var fields = requestBody(for: operation, sendOperationIdentifiers: sendOperationIdentifiers)
var variables = fields["variables"] as? GraphQLMap ?? GraphQLMap()
for fieldName in fieldsForFiles {
if
let value = variables[fieldName],
let arrayValue = value as? [JSONEncodable] {
let updatedArray: [JSONEncodable?] = arrayValue.map { _ in nil }
variables.updateValue(updatedArray, forKey: fieldName)
} else {
try formData.appendPart(string: data.debugDescription, name: name)
variables.updateValue(nil, forKey: fieldName)
}
}
fields["variables"] = variables

let operationData = try serializationFormat.serialize(value: fields)
formData.appendPart(data: operationData, name: "operations")

var map = [String: [String]]()
if files.count == 1 {
let firstFile = files.first!
map["0"] = ["variables.\(firstFile.fieldName)"]
} else {
for (index, file) in files.enumerated() {
map["\(index)"] = ["variables.\(file.fieldName).\(index)"]
}
}

let mapData = try serializationFormat.serialize(value: map)
formData.appendPart(data: mapData, name: "map")

files.forEach {
formData.appendPart(inputStream: $0.inputStream,
contentLength: $0.contentLength,
name: $0.fieldName,
contentType: $0.mimeType,
filename: $0.originalName)
for (index, file) in files.enumerated() {
formData.appendPart(inputStream: file.inputStream,
contentLength: file.contentLength,
name: "\(index)",
contentType: file.mimeType,
filename: file.originalName)
}

return formData
Expand Down
15 changes: 12 additions & 3 deletions Sources/ApolloWebSocket/SplitNetworkTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import Apollo

/// A network transport that sends subscriptions using one `NetworkTransport` and other requests using another `NetworkTransport`. Ideal for sending subscriptions via a web socket but everything else via HTTP.
public class SplitNetworkTransport {
private let httpNetworkTransport: NetworkTransport
private let httpNetworkTransport: UploadingNetworkTransport
private let webSocketNetworkTransport: NetworkTransport

/// Designated initializer
///
/// - Parameters:
/// - httpNetworkTransport: A `NetworkTransport` to use for non-subscription requests. Should generally be a `HTTPNetworkTransport` or something similar.
/// - httpNetworkTransport: An `UploadingNetworkTransport` to use for non-subscription requests. Should generally be a `HTTPNetworkTransport` or something similar.
/// - webSocketNetworkTransport: A `NetworkTransport` to use for subscription requests. Should generally be a `WebSocketTransport` or something similar.
public init(httpNetworkTransport: NetworkTransport, webSocketNetworkTransport: NetworkTransport) {
public init(httpNetworkTransport: UploadingNetworkTransport, webSocketNetworkTransport: NetworkTransport) {
self.httpNetworkTransport = httpNetworkTransport
self.webSocketNetworkTransport = webSocketNetworkTransport
}
Expand All @@ -30,3 +30,12 @@ extension SplitNetworkTransport: NetworkTransport {
}
}
}

// MARK: - UploadingNetworkTransport conformance

extension SplitNetworkTransport: UploadingNetworkTransport {

public func upload<Operation>(operation: Operation, files: [GraphQLFile], completionHandler: @escaping (_ result: Result<GraphQLResponse<Operation>, Error>) -> Void) -> Cancellable {
return httpNetworkTransport.upload(operation: operation, files: files, completionHandler: completionHandler)
}
}
20 changes: 16 additions & 4 deletions Tests/ApolloPerformanceTests/NormalizedCachingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ class NormalizedCachingTests: XCTestCase {
(1...100).forEach { number in
let expectation = self.expectation(description: "Loading query #\(number) from store")

store.load(query: query) { (result, error) in
XCTAssertEqual(result?.data?.hero?.name, "R2-D2")
store.load(query: query) { result in
switch result {
case .success(let graphQLResult):
XCTAssertEqual(graphQLResult.data?.hero?.name, "R2-D2")
case .failure(let error):
XCTFail("Unexpected error: \(error)")
}

expectation.fulfill()
}
}
Expand Down Expand Up @@ -111,8 +117,14 @@ class NormalizedCachingTests: XCTestCase {
(1...10).forEach { _ in
let expectation = self.expectation(description: "Loading query #\(number) from store")

store.load(query: query) { (result, error) in
XCTAssertEqual(result?.data?.hero?.friends?.first??.name, "Droid #\(number)")
store.load(query: query) { result in
switch result {
case .success(let graphQLResult):
XCTAssertEqual(graphQLResult.data?.hero?.friends?.first??.name, "Droid #\(number)")
case .failure(let error):
XCTFail("Unexpected error: \(error)")
}

expectation.fulfill()
}
}
Expand Down
Loading

0 comments on commit 426818a

Please sign in to comment.