From 3bfde020c3094e1ab96dfea837f5897526d954f0 Mon Sep 17 00:00:00 2001 From: Mike Pitre Date: Mon, 8 Nov 2021 13:58:36 -0500 Subject: [PATCH] GET method for `ApolloSchemaDownloader` (#2010) * GET method for ApolloSchemaDownloader * Minor improvements to HTTP method enum * Remove ApolloSchemaDownload scope from name * Add documentation * Add HTTP method string constants as output * Add error for unsupported HTTP method when using Apollo Registry * Move HTTP method support into DownloadMethod * Build requests based on DownloadMethod * Add tests for DownloadMethod HTTP method configurations * Clean up and clarify documentation * Add associated values to URL-related errors Co-authored-by: Calvin Cestari --- .../ApolloSchemaDownloadConfiguration.swift | 25 +++- .../ApolloSchemaDownloader.swift | 132 ++++++++++++------ .../ApolloSchemaInternalTests.swift | 87 ++++++++++++ 3 files changed, 202 insertions(+), 42 deletions(-) diff --git a/Sources/ApolloCodegenLib/ApolloSchemaDownloadConfiguration.swift b/Sources/ApolloCodegenLib/ApolloSchemaDownloadConfiguration.swift index 1f822253b0..db27ea36fe 100644 --- a/Sources/ApolloCodegenLib/ApolloSchemaDownloadConfiguration.swift +++ b/Sources/ApolloCodegenLib/ApolloSchemaDownloadConfiguration.swift @@ -9,7 +9,7 @@ public struct ApolloSchemaDownloadConfiguration { /// The Apollo Schema Registry, which serves as a central hub for managing your graph. case apolloRegistry(_ settings: ApolloRegistrySettings) /// GraphQL Introspection connecting to the specified URL. - case introspection(endpointURL: URL) + case introspection(endpointURL: URL, httpMethod: HTTPMethod = .POST) public struct ApolloRegistrySettings: Equatable { /// The API key to use when retrieving your schema from the Apollo Registry. @@ -33,11 +33,30 @@ public struct ApolloSchemaDownloadConfiguration { self.variant = variant } } + + /// The HTTP request method. This is an option on Introspection schema downloads only. Apollo Registry downloads are always + /// POST requests. + public enum HTTPMethod: Equatable, CustomStringConvertible { + /// Use POST for HTTP requests. This is the default for GraphQL. + case POST + /// Use GET for HTTP requests with the GraphQL query being sent in the query string parameter named in + /// `queryParameterName`. + case GET(queryParameterName: String) + + public var description: String { + switch self { + case .POST: + return "POST" + case .GET: + return "GET" + } + } + } public static func == (lhs: DownloadMethod, rhs: DownloadMethod) -> Bool { switch (lhs, rhs) { - case (.introspection(let lhsURL), introspection(let rhsURL)): - return lhsURL == rhsURL + case (.introspection(let lhsURL, let lhsHTTPMethod), .introspection(let rhsURL, let rhsHTTPMethod)): + return lhsURL == rhsURL && lhsHTTPMethod == rhsHTTPMethod case (.apolloRegistry(let lhsSettings), .apolloRegistry(let rhsSettings)): return lhsSettings == rhsSettings default: diff --git a/Sources/ApolloCodegenLib/ApolloSchemaDownloader.swift b/Sources/ApolloCodegenLib/ApolloSchemaDownloader.swift index d3bdf66978..83e975637b 100644 --- a/Sources/ApolloCodegenLib/ApolloSchemaDownloader.swift +++ b/Sources/ApolloCodegenLib/ApolloSchemaDownloader.swift @@ -13,6 +13,8 @@ public struct ApolloSchemaDownloader { case couldNotExtractSDLFromRegistryJSON case couldNotCreateSDLDataToWrite(schema: String) case couldNotConvertIntrospectionJSONToSDL(underlying: Error) + case couldNotCreateURLComponentsFromEndpointURL(url: URL) + case couldNotGetURLFromURLComponents(components: URLComponents) public var errorDescription: String? { switch self { @@ -29,7 +31,11 @@ public struct ApolloSchemaDownloader { case .couldNotCreateSDLDataToWrite(let schema): return "Could not convert SDL schema into data to write to the filesystem. Schema: \(schema)" case .couldNotConvertIntrospectionJSONToSDL(let underlying): - return "Could not convert downloaded introspection JSON into SDL format. Underlying error: \(underlying)" + return "Could not convert downloaded introspection JSON into SDL format. Underlying error: \(underlying)" + case .couldNotCreateURLComponentsFromEndpointURL(let url): + return "Could not create URLComponents from \(url) for Introspection." + case .couldNotGetURLFromURLComponents(let components): + return "Could not get URL from \(components)." } } } @@ -43,18 +49,36 @@ public struct ApolloSchemaDownloader { try FileManager.default.apollo.createContainingFolderIfNeeded(for: configuration.outputURL) switch configuration.downloadMethod { - case .introspection(let endpointURL): - try self.downloadViaIntrospection(from: endpointURL, configuration: configuration) + case .introspection(let endpointURL, let httpMethod): + try self.downloadViaIntrospection(from: endpointURL, httpMethod: httpMethod, configuration: configuration) case .apolloRegistry(let settings): try self.downloadFromRegistry(with: settings, configuration: configuration) } } + private static func request(url: URL, + httpMethod: ApolloSchemaDownloadConfiguration.DownloadMethod.HTTPMethod, + headers: [ApolloSchemaDownloadConfiguration.HTTPHeader], + bodyData: Data? = nil) -> URLRequest { + + var request = URLRequest(url: url) + + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + for header in headers { + request.addValue(header.value, forHTTPHeaderField: header.key) + } + + request.httpMethod = String(describing: httpMethod) + request.httpBody = bodyData + + return request + } + // MARK: - Schema Registry static let RegistryEndpoint = URL(string: "https://graphql.api.apollographql.com/api/graphql")! - private static let RegistryDownloadQuery = """ + static let RegistryDownloadQuery = """ query DownloadSchema($graphID: ID!, $variant: String!) { service(id: $graphID) { variant(name: $variant) { @@ -74,27 +98,9 @@ public struct ApolloSchemaDownloader { CodegenLogger.log("Downloading schema from registry", logLevel: .debug) - var variables = [String: String]() - variables["graphID"] = settings.graphID - - if let variant = settings.variant { - variables["variant"] = variant - } - - let body = UntypedGraphQLRequestBodyCreator.requestBody(for: self.RegistryDownloadQuery, - variables: variables, - operationName: "DownloadSchema") - - var urlRequest = URLRequest(url: self.RegistryEndpoint) - urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.addValue(settings.apiKey, forHTTPHeaderField: "x-api-key") - for header in configuration.headers { - urlRequest.addValue(header.value, forHTTPHeaderField: header.key) - } - urlRequest.httpMethod = "POST" - urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.sortedKeys]) + let urlRequest = try registryRequest(with: settings, headers: configuration.headers) let jsonOutputURL = configuration.outputURL.apollo.parentFolderURL().appendingPathComponent("registry_response.json") - + try URLDownloader().downloadSynchronously(with: urlRequest, to: jsonOutputURL, timeout: configuration.downloadTimeout) @@ -104,6 +110,31 @@ public struct ApolloSchemaDownloader { CodegenLogger.log("Successfully downloaded schema from registry", logLevel: .debug) } + static func registryRequest(with settings: ApolloSchemaDownloadConfiguration.DownloadMethod.ApolloRegistrySettings, + headers: [ApolloSchemaDownloadConfiguration.HTTPHeader]) throws -> URLRequest { + + var variables = [String: String]() + variables["graphID"] = settings.graphID + if let variant = settings.variant { + variables["variant"] = variant + } + + let requestBody = UntypedGraphQLRequestBodyCreator.requestBody(for: self.RegistryDownloadQuery, + variables: variables, + operationName: "DownloadSchema") + let bodyData = try JSONSerialization.data(withJSONObject: requestBody, options: [.sortedKeys]) + + var allHeaders = headers + allHeaders.append(ApolloSchemaDownloadConfiguration.HTTPHeader(key: "x-api-key", value: settings.apiKey)) + + let urlRequest = request(url: self.RegistryEndpoint, + httpMethod: .POST, + headers: allHeaders, + bodyData: bodyData) + + return urlRequest + } + static func convertFromRegistryJSONToSDLFile(jsonFileURL: URL, configuration: ApolloSchemaDownloadConfiguration) throws { let jsonData: Data @@ -143,7 +174,7 @@ public struct ApolloSchemaDownloader { // MARK: - Schema Introspection - private static let IntrospectionQuery = """ + static let IntrospectionQuery = """ query IntrospectionQuery { __schema { queryType { name } @@ -235,21 +266,13 @@ public struct ApolloSchemaDownloader { """ - static func downloadViaIntrospection(from endpointURL: URL, configuration: ApolloSchemaDownloadConfiguration) throws { + static func downloadViaIntrospection(from endpointURL: URL, + httpMethod: ApolloSchemaDownloadConfiguration.DownloadMethod.HTTPMethod, + configuration: ApolloSchemaDownloadConfiguration) throws { + CodegenLogger.log("Downloading schema via introspection from \(endpointURL)", logLevel: .debug) - - var urlRequest = URLRequest(url: endpointURL) - urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") - for header in configuration.headers { - urlRequest.addValue(header.value, forHTTPHeaderField: header.key) - } - - let body = UntypedGraphQLRequestBodyCreator.requestBody(for: self.IntrospectionQuery, - variables: nil, - operationName: "IntrospectionQuery") - urlRequest.httpMethod = "POST" - urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.sortedKeys]) + let urlRequest = try introspectionRequest(from: endpointURL, httpMethod: httpMethod, headers: configuration.headers) let jsonOutputURL = configuration.outputURL.apollo.parentFolderURL().appendingPathComponent("introspection_response.json") try URLDownloader().downloadSynchronously(with: urlRequest, @@ -260,7 +283,38 @@ public struct ApolloSchemaDownloader { CodegenLogger.log("Successfully downloaded schema via introspection", logLevel: .debug) } - + + static func introspectionRequest(from endpointURL: URL, + httpMethod: ApolloSchemaDownloadConfiguration.DownloadMethod.HTTPMethod, + headers: [ApolloSchemaDownloadConfiguration.HTTPHeader]) throws -> URLRequest { + let urlRequest: URLRequest + + switch httpMethod { + case .POST: + let requestBody = UntypedGraphQLRequestBodyCreator.requestBody(for: self.IntrospectionQuery, + variables: nil, + operationName: "IntrospectionQuery") + let bodyData = try JSONSerialization.data(withJSONObject: requestBody, options: [.sortedKeys]) + urlRequest = request(url: endpointURL, + httpMethod: httpMethod, + headers: headers, + bodyData: bodyData) + + case .GET(let queryParameterName): + guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: true) else { + throw SchemaDownloadError.couldNotCreateURLComponentsFromEndpointURL(url: endpointURL) + } + components.queryItems = [URLQueryItem(name: queryParameterName, value: IntrospectionQuery)] + + guard let url = components.url else { + throw SchemaDownloadError.couldNotGetURLFromURLComponents(components: components) + } + urlRequest = request(url: url, httpMethod: httpMethod, headers: headers) + } + + return urlRequest + } + static func convertFromIntrospectionJSONToSDLFile(jsonFileURL: URL, configuration: ApolloSchemaDownloadConfiguration) throws { let frontend = try ApolloCodegenFrontend() let schema: GraphQLSchema diff --git a/Tests/ApolloCodegenTests/ApolloSchemaInternalTests.swift b/Tests/ApolloCodegenTests/ApolloSchemaInternalTests.swift index 8efd7c5fcf..47fc4f4ab6 100644 --- a/Tests/ApolloCodegenTests/ApolloSchemaInternalTests.swift +++ b/Tests/ApolloCodegenTests/ApolloSchemaInternalTests.swift @@ -35,5 +35,92 @@ class ApolloSchemaInternalTests: XCTestCase { XCTAssertEqual(downloadConfiguration.outputURL, codegenOptions.urlToSchemaFile) } + + func testRequest_givenIntrospectionGETDownload_shouldOutputGETRequest() throws { + let url = ApolloTestSupport.TestURL.mockServer.url + let queryParameterName = "customParam" + let headers: [ApolloSchemaDownloadConfiguration.HTTPHeader] = [ + .init(key: "key1", value: "value1"), + .init(key: "key2", value: "value2") + ] + + let request = try ApolloSchemaDownloader.introspectionRequest(from: url, + httpMethod: .GET(queryParameterName: queryParameterName), + headers: headers) + + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertNil(request.httpBody) + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + for header in headers { + XCTAssertEqual(request.allHTTPHeaderFields?[header.key], header.value) + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) + components?.queryItems = [URLQueryItem(name: queryParameterName, value: ApolloSchemaDownloader.IntrospectionQuery)] + + XCTAssertNotNil(components?.url) + XCTAssertEqual(request.url, components?.url) + } + + func testRequest_givenIntrospectionPOSTDownload_shouldOutputPOSTRequest() throws { + let url = ApolloTestSupport.TestURL.mockServer.url + let headers: [ApolloSchemaDownloadConfiguration.HTTPHeader] = [ + .init(key: "key1", value: "value1"), + .init(key: "key2", value: "value2") + ] + + let request = try ApolloSchemaDownloader.introspectionRequest(from: url, httpMethod: .POST, headers: headers) + + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url, url) + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + for header in headers { + XCTAssertEqual(request.allHTTPHeaderFields?[header.key], header.value) + } + + let requestBody = UntypedGraphQLRequestBodyCreator.requestBody(for: ApolloSchemaDownloader.IntrospectionQuery, + variables: nil, + operationName: "IntrospectionQuery") + let bodyData = try JSONSerialization.data(withJSONObject: requestBody, options: [.sortedKeys]) + + XCTAssertEqual(request.httpBody, bodyData) + } + + func testRequest_givenRegistryDownload_shouldOutputPOSTRequest() throws { + let apiKey = "custom-api-key" + let graphID = "graph-id" + let variant = "a-variant" + let headers: [ApolloSchemaDownloadConfiguration.HTTPHeader] = [ + .init(key: "key1", value: "value1"), + .init(key: "key2", value: "value2"), + ] + + let request = try ApolloSchemaDownloader.registryRequest(with: .init(apiKey: apiKey, + graphID: graphID, + variant: variant), + headers: headers) + + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url, ApolloSchemaDownloader.RegistryEndpoint) + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(request.allHTTPHeaderFields?["x-api-key"], apiKey) + for header in headers { + XCTAssertEqual(request.allHTTPHeaderFields?[header.key], header.value) + } + + let variables: [String: String] = [ + "graphID": graphID, + "variant": variant + ] + let requestBody = UntypedGraphQLRequestBodyCreator.requestBody(for: ApolloSchemaDownloader.RegistryDownloadQuery, + variables: variables, + operationName: "DownloadSchema") + let bodyData = try JSONSerialization.data(withJSONObject: requestBody, options: [.sortedKeys]) + + XCTAssertEqual(request.httpBody, bodyData) + } }