Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Multipart parsers #126

Merged
Show file tree
Hide file tree
Changes from 4 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
118 changes: 118 additions & 0 deletions Tests/ApolloTests/Interceptors/MultipartResponseDeferParserTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import XCTest
import Nimble
@testable import Apollo
import ApolloAPI
import ApolloInternalTestHelpers

final class MultipartResponseDeferParserTests: XCTestCase {

let defaultTimeout = 0.5

// MARK: - Error tests

func test__error__givenChunk_withIncorrectContentType_shouldReturnError() throws {
let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor())

let expectation = expectation(description: "Received callback")

subject.intercept(
request: .mock(operation: MockQuery.mock()),
response: .mock(
headerFields: ["Content-Type": "multipart/mixed;boundary=graphql;deferSpec=20220824"],
data: """
--graphql
content-type: test/custom

{
"data" : {Ï
"key" : "value"
}
}
--graphql
""".crlfFormattedData()
)
) { result in
defer {
expectation.fulfill()
}

expect(result).to(beFailure { error in
expect(error).to(
matchError(MultipartResponseDeferParser.ParsingError.unsupportedContentType(type: "test/custom"))
)
})
}

wait(for: [expectation], timeout: defaultTimeout)
}

func test__error__givenUnrecognizableChunk_shouldReturnError() throws {
let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor())

let expectation = expectation(description: "Received callback")

subject.intercept(
request: .mock(operation: MockQuery.mock()),
response: .mock(
headerFields: ["Content-Type": "multipart/mixed;boundary=graphql;deferSpec=20220824"],
data: """
--graphql
content-type: application/json

not_a_valid_json_object
--graphql
""".crlfFormattedData()
)
) { result in
defer {
expectation.fulfill()
}

expect(result).to(beFailure { error in
expect(error).to(
matchError(MultipartResponseDeferParser.ParsingError.cannotParseChunkData)
)
})
}

wait(for: [expectation], timeout: defaultTimeout)
}

func test__error__givenChunk_withMissingPayload_shouldReturnError() throws {
let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor())

let expectation = expectation(description: "Received callback")

subject.intercept(
request: .mock(operation: MockQuery.mock()),
response: .mock(
headerFields: ["Content-Type": "multipart/mixed;boundary=graphql;deferSpec=20220824"],
data: """
--graphql
content-type: application/json

{
"key": "value"
}
--graphql
""".crlfFormattedData()
)
) { result in
defer {
expectation.fulfill()
}

expect(result).to(beFailure { error in
expect(error).to(
matchError(MultipartResponseDeferParser.ParsingError.cannotParsePayloadData)
)
})
}

wait(for: [expectation], timeout: defaultTimeout)
}

// MARK: Parsing tests

#warning("Need parsing tests - to be done after #3147")
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,30 @@ final class MultipartResponseParsingInterceptorTests: XCTestCase {

wait(for: [expectation], timeout: defaultTimeout)
}

func test__error__givenResponse_withInvalidData_shouldReturnError() throws {
let subject = InterceptorTester(interceptor: MultipartResponseParsingInterceptor())

let expectation = expectation(description: "Received callback")

subject.intercept(
request: .mock(operation: MockSubscription.mock()),
response: .mock(
headerFields: ["Content-Type": "multipart/mixed;boundary=\"graphql\";deferSpec=20220824"],
data: "🙃".data(using: .unicode)!
)
) { result in
defer {
expectation.fulfill()
}

expect(result).to(beFailure { error in
expect(error).to(
matchError(MultipartResponseParsingInterceptor.ParsingError.cannotParseResponseData)
)
})
}

wait(for: [expectation], timeout: defaultTimeout)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Nimble
import ApolloAPI
import ApolloInternalTestHelpers

final class MultipartResponseSubscriptionTests: XCTestCase {
final class MultipartResponseSubscriptionParserTests: XCTestCase {

let defaultTimeout = 0.5

Expand Down
111 changes: 104 additions & 7 deletions apollo-ios/Sources/Apollo/MultipartResponseDeferParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,111 @@ import ApolloAPI
#endif

struct MultipartResponseDeferParser: MultipartResponseSpecificationParser {
public enum ParsingError: Swift.Error, LocalizedError, Equatable {
case unsupportedContentType(type: String)
case cannotParseChunkData
case cannotParsePayloadData

public var errorDescription: String? {
switch self {

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 .cannotParsePayloadData:
return "The payload data could not be parsed."
}
}
}

private enum DataLine {
case contentHeader(type: String)
case json(object: JSONObject)
case unknown

init(_ value: String) {
self = Self.parse(value)
}

private static func parse(_ dataLine: String) -> DataLine {
var contentTypeHeader: StaticString { "content-type:" }

if dataLine.starts(with: contentTypeHeader.description) {
let contentType = (dataLine
.components(separatedBy: ":").last ?? dataLine
).trimmingCharacters(in: .whitespaces)

return .contentHeader(type: contentType)
}

if
let data = dataLine.data(using: .utf8),
let jsonObject = try? JSONSerializationFormat.deserialize(data: data) as? JSONObject
{
return .json(object: jsonObject)
}

return .unknown
}
}

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
static func parse(_ chunk: String) -> Result<Data?, Error> {
for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) {
switch DataLine(dataLine.trimmingCharacters(in: .newlines)) {
case let .contentHeader(type):
guard type == "application/json" else {
return .failure(ParsingError.unsupportedContentType(type: type))
}

case let .json(object):
if let hasNext = object.hasNext {
preconditionFailure("This will be done in #3147")
}

if let incremental = object.incremental {
preconditionFailure("This will be done in #3147")

} else {
guard
let _ = object.data,
let serialized: Data = try? JSONSerializationFormat.serialize(value: object)
else {
return .failure(ParsingError.cannotParsePayloadData)
}

return .success(serialized)
}

case .unknown:
return .failure(ParsingError.cannotParseChunkData)
}
}

return .success(nil)
}
}

fileprivate extension JSONObject {
var label: String? {
self["label"] as? String
}

var path: [String]? {
self["path"] as? [String]
}

var hasNext: Bool? {
self["hasNext"] as? Bool
}

var data: JSONObject? {
self["data"] as? JSONObject
}

var incremental: [JSONObject]? {
self["incremental"] as? [JSONObject]
}
}
80 changes: 49 additions & 31 deletions apollo-ios/Sources/Apollo/MultipartResponseParsingInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor {
public enum ParsingError: Error, LocalizedError, Equatable {
case noResponseToParse
case cannotParseResponse
case cannotParseResponseData

public var errorDescription: String? {
switch self {
case .noResponseToParse:
return "There is no response to parse. Check the order of your interceptors."
case .cannotParseResponse:
return "The response data could not be parsed."
case .cannotParseResponseData:
return "The response data could not be parsed."
}
}
}

private static let responseParsers: [String: MultipartResponseSpecificationParser.Type] = [
MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self
MultipartResponseSubscriptionParser.protocolSpec: MultipartResponseSubscriptionParser.self,
MultipartResponseDeferParser.protocolSpec: MultipartResponseDeferParser.self,
]

public var id: String = UUID().uuidString
Expand Down Expand Up @@ -71,36 +75,47 @@ public struct MultipartResponseParsingInterceptor: ApolloInterceptor {
return
}

let dataHandler: ((Data) -> Void) = { data in
let response = HTTPResponse<Operation>(
response: response.httpResponse,
rawData: data,
parsedResponse: nil
)

chain.proceedAsync(
request: request,
response: response,
interceptor: self,
completion: completion
)
}

let errorHandler: ((Error) -> Void) = { parserError in
guard let dataString = String(data: response.rawData, encoding: .utf8) else {
chain.handleErrorAsync(
parserError,
ParsingError.cannotParseResponseData,
request: request,
response: response,
completion: completion
)
return
}

parser.parse(
data: response.rawData,
boundary: boundary,
dataHandler: dataHandler,
errorHandler: errorHandler
)
for chunk in dataString.components(separatedBy: "--\(boundary)") {
if chunk.isEmpty || chunk.isBoundaryMarker { continue }

switch parser.parse(chunk) {
case let .success(data):
// Some chunks can be successfully parsed but do not require to be passed on to the next
// interceptor, such as an HTTP subscription heartbeat message.
if let data {
let response = HTTPResponse<Operation>(
response: response.httpResponse,
rawData: data,
parsedResponse: nil
)

chain.proceedAsync(
request: request,
response: response,
interceptor: self,
completion: completion
)
}

case let .failure(parserError):
chain.handleErrorAsync(
parserError,
request: request,
response: response,
completion: completion
)
}
}
}
}

Expand All @@ -111,11 +126,14 @@ protocol MultipartResponseSpecificationParser {
/// 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)
)
/// Function called to process each data line of the chunked response.
static func parse(_ chunk: String) -> Result<Data?, Error>
calvincestari marked this conversation as resolved.
Show resolved Hide resolved
}

extension MultipartResponseSpecificationParser {
static var dataLineSeparator: StaticString { "\r\n\r\n" }
}

fileprivate extension String {
var isBoundaryMarker: Bool { self == "--" }
}
Loading