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

feature: Adds support for GraphQL over HTTP media type #558

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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 @@ -168,6 +168,56 @@ final class MultipartResponseDeferParserTests: XCTestCase {
wait(for: [expectation], timeout: defaultTimeout)
}

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

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

let expected: JSONObject = [
"data": [
"key": "value"
],
"hasNext": true
]

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

--graphql
content-type: application/graphql-response+json

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

expect(result).to(beSuccess())

guard
let response = try! result.get(),
let deserialized = try! JSONSerialization.jsonObject(with: response.rawData) as? JSONObject
else {
return fail("data could not be deserialized!")
}

expect(deserialized).to(equal(expected))
}

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,47 @@ final class MultipartResponseSubscriptionParserTests: XCTestCase {
wait(for: [expectation], timeout: defaultTimeout)
}

func test__parsing__givenSingleChunk_withGraphQLOverHTTPContentType_shouldReturnSuccess() throws {
let network = buildNetworkTransport(responseData: """

--graphql
content-type: application/graphql-response+json

{
"payload": {
"data": {
"__typename": "Time",
"ticker": 1
}
}
}
--graphql--
""".crlfFormattedData()
)

let expectedData = try Time(data: [
"__typename": "Time",
"ticker": 1
], variables: nil)

let expectation = expectation(description: "Multipart data received")

_ = network.send(operation: MockSubscription<Time>()) { result in
defer {
expectation.fulfill()
}

switch (result) {
case let .success(data):
expect(data.data).to(equal(expectedData))
case let .failure(error):
fail("Unexpected failure result - \(error)")
}
}

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

func test__parsing__givenSingleChunk_withDashBoundaryInMessageBody_shouldReturnSuccess() throws {
let multipartBoundary = "-"
let network = buildNetworkTransport(
Expand Down
201 changes: 195 additions & 6 deletions Tests/ApolloTests/RequestChainTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@
return
}

XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/json")
XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/graphql-response+json,application/json")
expectation.fulfill()
}

Expand All @@ -400,7 +400,7 @@
return
}

XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/json")
XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseSubscriptionParser.protocolSpec),application/graphql-response+json,application/json")
XCTAssertNotNil(request.allHTTPHeaderFields?["Random"])
expectation.fulfill()
}
Expand Down Expand Up @@ -430,7 +430,7 @@
return
}

XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/json")
XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json")
expectation.fulfill()
}

Expand All @@ -455,7 +455,7 @@
return
}

XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/json")
XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json")
expectation.fulfill()
}

Expand All @@ -480,7 +480,7 @@
return
}

XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/json")
XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json")
XCTAssertNotNil(request.allHTTPHeaderFields?["Random"])
expectation.fulfill()
}
Expand Down Expand Up @@ -510,7 +510,7 @@
return
}

XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/json")
XCTAssertEqual(header, "multipart/mixed;\(MultipartResponseDeferParser.protocolSpec),application/graphql-response+json,application/json")
XCTAssertNotNil(request.allHTTPHeaderFields?["Random"])
expectation.fulfill()
}
Expand Down Expand Up @@ -933,7 +933,7 @@
throw XCTSkip("Flaky test skipped in PR #386- must be refactored or fixed in a separate PR.")

// given
let store = ApolloStore(cache: InMemoryNormalizedCache(records: [

Check warning on line 936 in Tests/ApolloTests/RequestChainTests.swift

View workflow job for this annotation

GitHub Actions / Apollo Unit Tests - macOS

code after 'throw' will never be executed
"QUERY_ROOT": [
"__typename": "Hero",
"name": "R2-D2"
Expand Down Expand Up @@ -1215,4 +1215,193 @@
// then
wait(for: expectations, timeout: 1, enforceOrder: true)
}

// MARK: Response Tests

func test__response__givenUnsuccessfulStatusCode_shouldFail() throws {
// given
let client = MockURLSessionClient(
response: .mock(
url: TestURL.mockServer.url,
statusCode: 500,
httpVersion: nil,
headerFields: nil
),
data: """
{
"data": {
"__typename": "Hero",
"name": "R2-D2"
}
}
""".data(using: .utf8)
)

let provider = DefaultInterceptorProvider(
client: client,
store: ApolloStore()
)

let transport = RequestChainNetworkTransport(
interceptorProvider: provider,
endpointURL: TestURL.mockServer.url
)

let expectation = expectation(description: "Response received")

_ = transport.send(operation: MockQuery<Hero>()) { result in
switch result {
case .success:
XCTFail("Unexpected response: \(result)")

case .failure:
expectation.fulfill()
}
}

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

// This test is odd because you might assume it would fail but there is no content-type checking on standard
// GraphQL response parsing. So this test is here to ensure that existing behaviour does not change.
func test__response__givenUnknownContentType_shouldNotFail() throws {
Copy link
Member Author

Choose a reason for hiding this comment

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

I've added tests to keep the existing behaviour of standard GraphQL response parsing including this odd one.

// given
let client = MockURLSessionClient(
response: .mock(
url: TestURL.mockServer.url,
statusCode: 200,
httpVersion: nil,
headerFields: ["content-type": "unknown/type"]
),
data: """
{
"data": {
"__typename": "Hero",
"name": "R2-D2"
}
}
""".data(using: .utf8)
)

let provider = DefaultInterceptorProvider(
client: client,
store: ApolloStore()
)

let transport = RequestChainNetworkTransport(
interceptorProvider: provider,
endpointURL: TestURL.mockServer.url
)

let expectation = expectation(description: "Response received")

_ = transport.send(operation: MockQuery<Hero>()) { result in
switch result {
case let .success(responseData):
XCTAssertEqual(responseData.data?.__typename, "Hero")
XCTAssertEqual(responseData.data?.name, "R2-D2")

expectation.fulfill()

case .failure:
XCTFail("Unexpected response: \(result)")
}
}

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

func test__response__givenJSONContentType_shouldSucceed() throws {
// given
let client = MockURLSessionClient(
response: .mock(
url: TestURL.mockServer.url,
statusCode: 200,
httpVersion: nil,
headerFields: ["content-type": "application/json"]
),
data: """
{
"data": {
"__typename": "Hero",
"name": "R2-D2"
}
}
""".data(using: .utf8)
)

let provider = DefaultInterceptorProvider(
client: client,
store: ApolloStore()
)

let transport = RequestChainNetworkTransport(
interceptorProvider: provider,
endpointURL: TestURL.mockServer.url
)

let expectation = expectation(description: "Response received")

_ = transport.send(operation: MockQuery<Hero>()) { result in
switch result {
case let .success(responseData):
XCTAssertEqual(responseData.data?.__typename, "Hero")
XCTAssertEqual(responseData.data?.name, "R2-D2")

expectation.fulfill()

case .failure:
XCTFail("Unexpected response: \(result)")
}
}

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

func test__response__givenGraphQLOverHTTPContentType_shouldSucceed() throws {
// given
let client = MockURLSessionClient(
response: .mock(
url: TestURL.mockServer.url,
statusCode: 200,
httpVersion: nil,
headerFields: ["content-type": "application/graphql-response+json"]
),
data: """
{
"data": {
"__typename": "Hero",
"name": "R2-D2"
}
}
""".data(using: .utf8)
)

let provider = DefaultInterceptorProvider(
client: client,
store: ApolloStore()
)

let transport = RequestChainNetworkTransport(
interceptorProvider: provider,
endpointURL: TestURL.mockServer.url
)

let expectation = expectation(description: "Response received")

_ = transport.send(operation: MockQuery<Hero>()) { result in
switch result {
case let .success(responseData):
XCTAssertEqual(responseData.data?.__typename, "Hero")
XCTAssertEqual(responseData.data?.name, "R2-D2")

expectation.fulfill()

case .failure:
XCTFail("Unexpected response: \(result)")
}
}

wait(for: [expectation], timeout: 1)
}
}
4 changes: 2 additions & 2 deletions apollo-ios/Sources/Apollo/MultipartResponseDeferParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct MultipartResponseDeferParser: MultipartResponseSpecificationParser {
switch self {

case let .unsupportedContentType(type):
return "Unsupported content type: application/json is required but got \(type)."
return "Unsupported content type: 'application/graphql-response+json' or 'application/json' are supported, received '\(type)'."
case .cannotParseChunkData:
return "The chunk data could not be parsed."
case .cannotParsePayloadData:
Expand Down Expand Up @@ -59,7 +59,7 @@ struct MultipartResponseDeferParser: MultipartResponseSpecificationParser {
for dataLine in chunk.components(separatedBy: Self.dataLineSeparator.description) {
switch DataLine(dataLine.trimmingCharacters(in: .newlines)) {
case let .contentHeader(type):
guard type == "application/json" else {
guard type == "application/graphql-response+json" || type == "application/json" else {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should create a helper method for this. We're doing string comparisons against strings that are really constants. And we're doing the same comparison in two places. This is prone to future bugs.

We should store the constants as StaticStrings and have a helper method to check the content header.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll put this PR back into draft and make that change when I get back.

return .failure(ParsingError.unsupportedContentType(type: type))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser
switch self {

case let .unsupportedContentType(type):
return "Unsupported content type: application/json is required but got \(type)."
return "Unsupported content type: 'application/graphql-response+json' or 'application/json' are supported, received '\(type)'."
case .cannotParseChunkData:
return "The chunk data could not be parsed."
case let .irrecoverableError(message):
Expand Down Expand Up @@ -75,7 +75,7 @@ struct MultipartResponseSubscriptionParser: MultipartResponseSpecificationParser
break

case let .contentHeader(type):
guard type == "application/json" else {
guard type == "application/graphql-response+json" || type == "application/json" else {
return .failure(ParsingError.unsupportedContentType(type: type))
}

Expand Down
Loading
Loading