diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df281400..c002be58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,41 +32,38 @@ jobs: xcode-version: latest-stable - uses: actions/checkout@v3 - name: Build and test - run: swift test --parallel --enable-test-discovery + run: swift test --parallel linux: - name: Test on Linux + name: Test Swift ${{ matrix.swift }} runs-on: ubuntu-latest - steps: - - uses: swift-actions/setup-swift@v2 - - uses: actions/checkout@v3 - - name: Test - run: swift test --parallel --enable-code-coverage - - name: Get test coverage html - run: | - llvm-cov show \ - $(swift build --show-bin-path)/GraphitiPackageTests.xctest \ - --instr-profile $(swift build --show-bin-path)/codecov/default.profdata \ - --ignore-filename-regex="\.build|Tests" \ - --format html \ - --output-dir=.test-coverage - - name: Upload test coverage html - uses: actions/upload-artifact@v3 - with: - name: test-coverage-report - path: .test-coverage - - backcompat-ubuntu-22_04: - name: Test Swift ${{ matrix.swift }} on Ubuntu 22.04 - runs-on: ubuntu-22.04 + container: + image: swift:${{ matrix.swift }} strategy: matrix: - swift: ["5.8", "5.9", "5.10"] + swift: ["5.8", "5.9", "5.10", "6.0", "6.1"] steps: - - uses: swift-actions/setup-swift@v2 - with: - swift-version: ${{ matrix.swift }} - uses: actions/checkout@v3 - name: Test run: swift test --parallel + # TODO: Add test coverage upload but it's currently not working with Swift 6.1.0/Ubuntu-latest + # test-coverage: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v3 + # - name: Test + # run: swift test --parallel --enable-code-coverage + # - name: Get test coverage html + # run: | + # llvm-cov show \ + # $(swift build --show-bin-path)/GraphitiPackageTests.xctest \ + # --instr-profile $(swift build --show-bin-path)/codecov/default.profdata \ + # --ignore-filename-regex="\.build|Tests" \ + # --format html \ + # --output-dir=.test-coverage + # - name: Upload test coverage html + # uses: actions/upload-artifact@v4 + # with: + # name: test-coverage-report + # path: .test-coverage diff --git a/Sources/Graphiti/Federation/Key/Type+Key.swift b/Sources/Graphiti/Federation/Key/Type+Key.swift index 0215547d..d1e8443c 100644 --- a/Sources/Graphiti/Federation/Key/Type+Key.swift +++ b/Sources/Graphiti/Federation/Key/Type+Key.swift @@ -1,7 +1,6 @@ import GraphQL public extension Type { - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -9,6 +8,7 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key value. The name of this argument must match a Type field. /// - Returns: Self for chaining. + @discardableResult func key( at function: @escaping AsyncResolve, @ArgumentComponentBuilder _ argument: () -> ArgumentComponent @@ -17,7 +17,6 @@ public extension Type { return self } - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -25,6 +24,7 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key values. The names of these arguments must match Type fields. /// - Returns: Self for chaining. + @discardableResult func key( at function: @escaping AsyncResolve, @ArgumentComponentBuilder _ arguments: () @@ -34,7 +34,6 @@ public extension Type { return self } - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -42,6 +41,7 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key value. The name of this argument must match a Type field. /// - Returns: Self for chaining. + @discardableResult func key( at function: @escaping SimpleAsyncResolve, @ArgumentComponentBuilder _ argument: () -> ArgumentComponent @@ -50,7 +50,6 @@ public extension Type { return self } - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -58,6 +57,7 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key values. The names of these arguments must match Type fields. /// - Returns: Self for chaining. + @discardableResult func key( at function: @escaping SimpleAsyncResolve, @ArgumentComponentBuilder _ arguments: () @@ -67,7 +67,6 @@ public extension Type { return self } - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -75,6 +74,7 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key value. The name of this argument must match a Type field. /// - Returns: Self for chaining. + @discardableResult func key( at function: @escaping SyncResolve, @ArgumentComponentBuilder _ arguments: () @@ -84,7 +84,6 @@ public extension Type { return self } - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -92,6 +91,7 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key values. The names of these arguments must match Type fields. /// - Returns: Self for chaining. + @discardableResult func key( at function: @escaping SyncResolve, @ArgumentComponentBuilder _ argument: () -> ArgumentComponent @@ -102,8 +102,6 @@ public extension Type { } public extension Type { - @available(macOS 10.15, iOS 15, watchOS 8, tvOS 15, *) - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -111,6 +109,8 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key value. The name of this argument must match a Type field. /// - Returns: Self for chaining. + @available(macOS 10.15, iOS 15, watchOS 8, tvOS 15, *) + @discardableResult func key( at function: @escaping ConcurrentResolve, @ArgumentComponentBuilder _ argument: () -> ArgumentComponent @@ -119,8 +119,6 @@ public extension Type { return self } - @available(macOS 10.15, iOS 15, watchOS 8, tvOS 15, *) - @discardableResult /// Define and add the federated key to this type. /// /// For more information, see https://www.apollographql.com/docs/federation/entities @@ -128,6 +126,8 @@ public extension Type { /// - function: The resolver function used to load this entity based on the key value. /// - _: The key values. The names of these arguments must match Type fields. /// - Returns: Self for chaining. + @available(macOS 10.15, iOS 15, watchOS 8, tvOS 15, *) + @discardableResult func key( at function: @escaping ConcurrentResolve, @ArgumentComponentBuilder _ arguments: () -> [ArgumentComponent] diff --git a/Sources/Graphiti/Schema/Schema.swift b/Sources/Graphiti/Schema/Schema.swift index 959b297f..590b8607 100644 --- a/Sources/Graphiti/Schema/Schema.swift +++ b/Sources/Graphiti/Schema/Schema.swift @@ -1,6 +1,10 @@ import GraphQL import NIO +public struct SchemaError: Error, Equatable { + let description: String +} + public final class Schema { public let schema: GraphQLSchema @@ -16,11 +20,14 @@ public final class Schema { try component.update(typeProvider: typeProvider, coders: coders) } - guard let query = typeProvider.query else { - fatalError("Query type is required.") + guard typeProvider.query != nil || !typeProvider.federatedResolvers.isEmpty else { + throw SchemaError( + description: "Schema must contain at least 1 query or federated resolver" + ) } + schema = try GraphQLSchema( - query: query, + query: typeProvider.query, mutation: typeProvider.mutation, subscription: typeProvider.subscription, types: typeProvider.types, diff --git a/Sources/Graphiti/SchemaBuilders/SchemaBuilder.swift b/Sources/Graphiti/SchemaBuilders/SchemaBuilder.swift index b96a2d37..55ea3f8d 100644 --- a/Sources/Graphiti/SchemaBuilders/SchemaBuilder.swift +++ b/Sources/Graphiti/SchemaBuilders/SchemaBuilder.swift @@ -27,19 +27,19 @@ public final class SchemaBuilder { subscriptionFields = [] } - @discardableResult /// Allows for setting API encoders and decoders with customized settings. /// - Parameter newCoders: The new coders to use /// - Returns: This object for method chaining + @discardableResult public func setCoders(to newCoders: Coders) -> Self { coders = newCoders return self } - @discardableResult /// Allows for setting SDL for federated subgraphs. /// - Parameter newSDL: The new SDL to use /// - Returns: This object for method chaining + @discardableResult public func setFederatedSDL(to newSDL: String) -> Self { federatedSDL = newSDL return self @@ -63,10 +63,10 @@ public final class SchemaBuilder { return self } - @discardableResult /// Adds multiple query operation definitions to the schema. /// - Parameter component: The query operations to add /// - Returns: This object for method chaining + @discardableResult public func add( @TypeComponentBuilder _ components: () -> [TypeComponent] @@ -77,10 +77,10 @@ public final class SchemaBuilder { return self } - @discardableResult /// Adds multiple query operation definitions to the schema. /// - Parameter component: The query operations to add /// - Returns: This object for method chaining + @discardableResult public func addQuery( @FieldComponentBuilder _ fields: () -> [FieldComponent] @@ -91,10 +91,10 @@ public final class SchemaBuilder { return self } - @discardableResult /// Adds multiple mutation operation definitions to the schema. /// - Parameter component: The query operations to add /// - Returns: This object for method chaining + @discardableResult public func addMutation( @FieldComponentBuilder _ fields: () -> [FieldComponent] @@ -105,10 +105,10 @@ public final class SchemaBuilder { return self } - @discardableResult /// Adds multiple subscription operation definitions to the schema. /// - Parameter component: The query operations to add /// - Returns: This object for method chaining + @discardableResult public func addSubscription( @FieldComponentBuilder _ fields: () -> [FieldComponent] @@ -119,10 +119,10 @@ public final class SchemaBuilder { return self } - @discardableResult /// Adds multiple type, query, mutation, and subscription definitions using partial schemas to the schema. /// - Parameter partials: Partial schemas that declare types, query, mutation, and/or subscription definiton /// - Returns: Thie object for method chaining + @discardableResult public func use(partials: [PartialSchema]) -> Self { for type in partials.flatMap({ $0.types }) { typeComponents.append(type) diff --git a/Tests/GraphitiTests/FederationTests/FederationOnlySchemaTests.swift b/Tests/GraphitiTests/FederationTests/FederationOnlySchemaTests.swift new file mode 100644 index 00000000..9092c8d5 --- /dev/null +++ b/Tests/GraphitiTests/FederationTests/FederationOnlySchemaTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Graphiti +import GraphQL +import NIO +import XCTest + +final class FederationOnlySchemaTests: XCTestCase { + private var group: MultiThreadedEventLoopGroup! + private var api: FederationOnlyAPI! + + struct Profile: Codable { + let name: String + let email: String? + } + + struct User: Codable { + let id: String + + func profile(context _: NoContext, args _: NoArguments) async throws -> Profile { + if id == "1" { + return Profile(name: "User \(id)", email: nil) + } else { + return Profile(name: "User \(id)", email: "\(id)@example.com") + } + } + + struct Key: Codable { + let id: String + } + } + + struct FederationOnlyResolver { + func user(context _: NoContext, key: User.Key) async throws -> User { + User(id: key.id) + } + } + + struct FederationOnlyAPI: API { + var resolver: FederationOnlyResolver + var schema: Schema + } + + static let federatedSDL: String = + """ + type User @key(fields: "id") { + id: String! + profile: Profile! + } + + type Profile { + name: String! + email: String + } + """ + + override func setUpWithError() throws { + let schema = try SchemaBuilder(FederationOnlyResolver.self, NoContext.self) + .setFederatedSDL(to: Self.federatedSDL) + .add { + Type(User.self) { + Field("id", at: \.id) + Field("profile", at: User.profile) + } + .key(at: FederationOnlyResolver.user) { + Argument("id", at: \.id) + } + + Type(Profile.self) { + Field("name", at: \.name) + Field("email", at: \.email) + } + } + .build() + group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + api = FederationOnlyAPI(resolver: FederationOnlyResolver(), schema: schema) + } + + override func tearDownWithError() throws { + try group.syncShutdownGracefully() + group = nil + api = nil + } + + func execute(request: String, variables: [String: Map] = [:]) throws -> GraphQLResult { + try api + .execute( + request: request, + context: NoContext(), + on: group, + variables: variables + ) + .wait() + } + + func testUserFederationSimple() throws { + let representations: [String: Map] = [ + "representations": [ + ["__typename": "User", "id": "1234"], + ], + ] + + let query = + """ + query user($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on User { + id + } + } + } + """ + + try XCTAssertEqual( + execute(request: query, variables: representations), + GraphQLResult(data: [ + "_entities": [ + [ + "id": "1234", + ], + ], + ]) + ) + } + + func testUserFederationNested() throws { + let representations: [String: Map] = [ + "representations": [ + ["__typename": "User", "id": "1234"], + ], + ] + + let query = + """ + query user($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on User { + id + profile { name, email } + } + } + } + """ + + try XCTAssertEqual( + execute(request: query, variables: representations), + GraphQLResult(data: [ + "_entities": [ + [ + "id": "1234", + "profile": [ + "name": "User 1234", + "email": "1234@example.com", + ], + ], + ], + ]) + ) + } + + func testUserFederationNestedOptional() throws { + let representations: [String: Map] = [ + "representations": [ + ["__typename": "User", "id": "1"], + ], + ] + + let query = + """ + query user($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on User { + id + profile { name, email } + } + } + } + """ + + try XCTAssertEqual( + execute(request: query, variables: representations), + GraphQLResult(data: [ + "_entities": [ + [ + "id": "1", + "profile": [ + "name": "User 1", + "email": .null, + ], + ], + ], + ]) + ) + } +} diff --git a/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift b/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift index 90ead0cc..186d4984 100644 --- a/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift +++ b/Tests/GraphitiTests/HelloWorldTests/HelloWorldAsyncTests.swift @@ -318,8 +318,8 @@ class HelloWorldAsyncTests: XCTestCase { } } -@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) /// A very simple publish/subscriber used for testing +@available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) class SimplePubSub { private var subscribers: [Subscriber] diff --git a/Tests/GraphitiTests/SchemaTests.swift b/Tests/GraphitiTests/SchemaTests.swift index ef2c79f9..b4db8ce8 100644 --- a/Tests/GraphitiTests/SchemaTests.swift +++ b/Tests/GraphitiTests/SchemaTests.swift @@ -140,6 +140,29 @@ class SchemaTests: XCTestCase { ]) ) } + + func testSchemaWithNoQuery() { + struct User: Codable { + let id: String + } + + struct TestResolver {} + + do { + let _ = try Schema { + Type(User.self) { + Field("id", at: \.id) + } + } + } catch { + XCTAssertEqual( + error as? SchemaError, + SchemaError( + description: "Schema must contain at least 1 query or federated resolver" + ) + ) + } + } } private class TestAPI: API {