Skip to content

Commit

Permalink
merge: branch into (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
calvincestari authored and gh-action-runner committed Nov 2, 2023
2 parents d5c6b1f + 5cc8f75 commit 9759ba2
Show file tree
Hide file tree
Showing 15 changed files with 880 additions and 199 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ xcuserdata/
*.xcscmblueprint
.DS_Store

## Visual Studio Code
.vscode/launch.json

## Obj-C/Swift specific
*.hmap
*.ipa
Expand Down
471 changes: 471 additions & 0 deletions Design/3093-graphql-defer.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Sources/Apollo/FieldSelectionCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,16 @@ struct DefaultFieldSelectionCollector: FieldSelectionCollector {
info: info)
}

case .deferred(_, _, _):
assertionFailure("Defer execution must be implemented (#3145).")
case let .fragment(fragment):
groupedFields.addFulfilledFragment(fragment)
try collectFields(from: fragment.__selections,
into: &groupedFields,
for: object,
info: info)

// TODO: _ is fine for now but will need to be handled in #3145
case let .inlineFragment(typeCase):
if let runtimeType = info.runtimeObjectType(for: object),
typeCase.__parentType.canBeConverted(from: runtimeType) {
Expand Down Expand Up @@ -145,7 +148,8 @@ struct CustomCacheDataWritingFieldSelectionCollector: FieldSelectionCollector {
for: object,
info: info,
asConditionalFields: true)

case .deferred(_, _, _):
assertionFailure("Defer execution must be implemented (#3145).")
case let .fragment(fragment):
if groupedFields.fulfilledFragments.contains(type: fragment) {
try collectFields(from: fragment.__selections,
Expand Down
47 changes: 40 additions & 7 deletions Sources/Apollo/HTTPURLResponse+Helpers.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
import Foundation

// MARK: Status extensions
extension HTTPURLResponse {
var isSuccessful: Bool {
return (200..<300).contains(statusCode)
}
}

// MARK: Multipart extensions
extension HTTPURLResponse {
/// Returns true if the `Content-Type` HTTP header contains the `multipart/mixed` MIME type.
var isMultipart: Bool {
return (allHeaderFields["Content-Type"] as? String)?.contains("multipart/mixed") ?? false
}

var multipartBoundary: String? {
guard let contentType = allHeaderFields["Content-Type"] as? String else { return nil }
struct MultipartHeaderComponents {
let media: String?
let boundary: String?
let `protocol`: String?

init(media: String? = nil, boundary: String? = nil, protocol: String? = nil) {
self.media = media
self.boundary = boundary
self.protocol = `protocol`
}
}

/// Components of the `Content-Type` header specifically related to the `multipart` media type.
var multipartHeaderComponents: MultipartHeaderComponents {
guard let contentType = allHeaderFields["Content-Type"] as? String else {
return MultipartHeaderComponents()
}

let marker = "boundary="
let markerLength = marker.count
var media: String? = nil
var boundary: String? = nil
var `protocol`: String? = nil

for component in contentType.components(separatedBy: ";") {
let directive = component.trimmingCharacters(in: .whitespaces)
if directive.prefix(markerLength) == marker {

if directive.starts(with: "multipart/") {
media = directive.components(separatedBy: "/").last
continue
}

if directive.starts(with: "boundary=") {
if let markerEndIndex = directive.firstIndex(of: "=") {
var startIndex = directive.index(markerEndIndex, offsetBy: 1)
if directive[startIndex] == "\"" {
Expand All @@ -28,11 +55,17 @@ extension HTTPURLResponse {
endIndex = directive.index(before: endIndex)
}

return String(directive[startIndex...endIndex])
boundary = String(directive[startIndex...endIndex])
}
continue
}

if directive.contains("Spec=") {
`protocol` = directive
continue
}
}

return nil
return MultipartHeaderComponents(media: media, boundary: boundary, protocol: `protocol`)
}
}
17 changes: 17 additions & 0 deletions Sources/Apollo/MultipartResponseDeferParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
#if !COCOAPODS
import ApolloAPI
#endif

struct MultipartResponseDeferParser: MultipartResponseSpecificationParser {
static let protocolSpec: String = "deferSpec=20220824"

static func parse(
data: Data,
boundary: String,
dataHandler: ((Data) -> Void),
errorHandler: ((Error) -> Void)
) {
// TODO: Will be implemented in #3146
}
}
183 changes: 51 additions & 132 deletions Sources/Apollo/MultipartResponseParsingInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,23 @@ import ApolloAPI
/// Parses multipart response data into chunks and forwards each on to the next interceptor.
public struct MultipartResponseParsingInterceptor: ApolloInterceptor {

public enum MultipartResponseParsingError: Error, LocalizedError, Equatable {
public enum ParsingError: Error, LocalizedError, Equatable {
case noResponseToParse
case cannotParseResponseData
case unsupportedContentType(type: String)
case cannotParseChunkData
case irrecoverableError(message: String?)
case cannotParsePayloadData
case cannotParseResponse

public var errorDescription: String? {
switch self {
case .noResponseToParse:
return "There is no response to parse. Check the order of your interceptors."
case .cannotParseResponseData:
case .cannotParseResponse:
return "The response data could not be parsed."
case let .unsupportedContentType(type):
return "Unsupported content type: application/json is required but got \(type)."
case .cannotParseChunkData:
return "The chunk data could not be parsed."
case let .irrecoverableError(message):
return "An irrecoverable error occured: \(message ?? "unknown")."
case .cannotParsePayloadData:
return "The payload data could not be parsed."
}
}
}

private enum ChunkedDataLine {
case heartbeat
case contentHeader(type: String)
case json(object: JSONObject)
case unknown
}

private static let dataLineSeparator: StaticString = "\r\n\r\n"
private static let contentTypeHeader: StaticString = "content-type:"
private static let heartbeat: StaticString = "{}"
private static let responseParsers: [String: MultipartResponseSpecificationParser.Type] = [
MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self
]

public var id: String = UUID().uuidString

Expand All @@ -56,7 +37,7 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor {

guard let response else {
chain.handleErrorAsync(
MultipartResponseParsingError.noResponseToParse,
ParsingError.noResponseToParse,
request: request,
response: response,
completion: completion
Expand All @@ -74,129 +55,67 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor {
return
}

let multipartComponents = response.httpResponse.multipartHeaderComponents

guard
let boundaryString = response.httpResponse.multipartBoundary,
let dataString = String(data: response.rawData, encoding: .utf8)
let boundary = multipartComponents.boundary,
let `protocol` = multipartComponents.protocol,
let parser = Self.responseParsers[`protocol`]
else {
chain.handleErrorAsync(
MultipartResponseParsingError.cannotParseResponseData,
ParsingError.cannotParseResponse,
request: request,
response: response,
completion: completion
)
return
}

for chunk in dataString.components(separatedBy: "--\(boundaryString)") {
if chunk.isEmpty || chunk.isBoundaryPrefix { continue }

for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) {
switch (parse(dataLine: dataLine.trimmingCharacters(in: .newlines))) {
case .heartbeat:
// Periodically sent by the router - noop
continue

case let .contentHeader(type):
guard type == "application/json" else {
chain.handleErrorAsync(
MultipartResponseParsingError.unsupportedContentType(type: type),
request: request,
response: response,
completion: completion
)
return
}

case let .json(object):
if let errors = object["errors"] as? [JSONObject] {
let message = errors.first?["message"] as? String

chain.handleErrorAsync(
MultipartResponseParsingError.irrecoverableError(message: message),
request: request,
response: response,
completion: completion
)

// These are fatal-level transport errors, don't process anything else.
return
}

guard let payload = object["payload"] else {
chain.handleErrorAsync(
MultipartResponseParsingError.cannotParsePayloadData,
request: request,
response: response,
completion: completion
)
return
}

if payload is NSNull {
// `payload` can be null such as in the case of a transport error
continue
}

guard
let payload = payload as? JSONObject,
let data: Data = try? JSONSerializationFormat.serialize(value: payload)
else {
chain.handleErrorAsync(
MultipartResponseParsingError.cannotParsePayloadData,
request: request,
response: response,
completion: completion
)
return
}

let response = HTTPResponse<Operation>(
response: response.httpResponse,
rawData: data,
parsedResponse: nil
)
chain.proceedAsync(
request: request,
response: response,
interceptor: self,
completion: completion
)

case .unknown:
chain.handleErrorAsync(
MultipartResponseParsingError.cannotParseChunkData,
request: request,
response: response,
completion: completion
)
}
}
}
}

/// Parses the data line of a multipart response chunk
private func parse(dataLine: String) -> ChunkedDataLine {
if dataLine == Self.heartbeat.description {
return .heartbeat
}
let dataHandler: ((Data) -> Void) = { data in
let response = HTTPResponse<Operation>(
response: response.httpResponse,
rawData: data,
parsedResponse: nil
)

if dataLine.starts(with: Self.contentTypeHeader.description) {
return .contentHeader(type: (dataLine.components(separatedBy: ":").last ?? dataLine)
.trimmingCharacters(in: .whitespaces)
chain.proceedAsync(
request: request,
response: response,
interceptor: self,
completion: completion
)
}

if
let data = dataLine.data(using: .utf8),
let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject
{
return .json(object: jsonObject)
let errorHandler: ((Error) -> Void) = { parserError in
chain.handleErrorAsync(
parserError,
request: request,
response: response,
completion: completion
)
}

return .unknown
parser.parse(
data: response.rawData,
boundary: boundary,
dataHandler: dataHandler,
errorHandler: errorHandler
)
}
}

fileprivate extension String {
var isBoundaryPrefix: Bool { self == "--" }
/// A protocol that multipart response parsers must conform to in order to be added to the list of
/// available response specification parsers.
protocol MultipartResponseSpecificationParser {
/// The specification string matching what is expected to be received in the `Content-Type` header
/// in an HTTP response.
static var protocolSpec: String { get }

/// Function that will be called to process the response data.
static func parse(
data: Data,
boundary: String,
dataHandler: ((Data) -> Void),
errorHandler: ((Error) -> Void)
)
}

0 comments on commit 9759ba2

Please sign in to comment.