diff --git a/Package.resolved b/Package.resolved index 5736895..4c48541 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,241 +1,330 @@ { - "object": { - "pins": [ - { - "package": "async-http-client", - "repositoryURL": "https://github.com/swift-server/async-http-client.git", - "state": { - "branch": null, - "revision": "a22083713ee90808d527d0baa290c2fb13ca3096", - "version": "1.21.1" - } - }, - { - "package": "async-kit", - "repositoryURL": "https://github.com/vapor/async-kit.git", - "state": { - "branch": null, - "revision": "7ece208cd401687641c88367a00e3ea2b04311f1", - "version": "1.19.0" - } - }, - { - "package": "console-kit", - "repositoryURL": "https://github.com/vapor/console-kit.git", - "state": { - "branch": null, - "revision": "9f7932f22ab6f64aafadc14491e694179b7d0f6f", - "version": "4.14.3" - } - }, - { - "package": "fluent", - "repositoryURL": "https://github.com/vapor/fluent.git", - "state": { - "branch": null, - "revision": "dfcbeba27a576c20ff181d496f21ecd45d2c1a71", - "version": "4.11.0" - } - }, - { - "package": "fluent-kit", - "repositoryURL": "https://github.com/vapor/fluent-kit.git", - "state": { - "branch": null, - "revision": "d69efce21242ad4dba6935cc1b8d5637281604d5", - "version": "1.48.5" - } - }, - { - "package": "Graphiti", - "repositoryURL": "https://github.com/GraphQLSwift/Graphiti.git", - "state": { - "branch": null, - "revision": "42623e2f7c717c54dfe2f9b595d87437aafbf9a3", - "version": "1.14.0" - } - }, - { - "package": "GraphQL", - "repositoryURL": "https://github.com/GraphQLSwift/GraphQL.git", - "state": { - "branch": null, - "revision": "3cf2dbce764e7ccff8447d0b7d4634c0438449d3", - "version": "2.9.2" - } - }, - { - "package": "multipart-kit", - "repositoryURL": "https://github.com/vapor/multipart-kit.git", - "state": { - "branch": null, - "revision": "a31236f24bfd2ea2f520a74575881f6731d7ae68", - "version": "4.7.0" - } - }, - { - "package": "routing-kit", - "repositoryURL": "https://github.com/vapor/routing-kit.git", - "state": { - "branch": null, - "revision": "8c9a227476555c55837e569be71944e02a056b72", - "version": "4.9.1" - } - }, - { - "package": "sql-kit", - "repositoryURL": "https://github.com/vapor/sql-kit.git", - "state": { - "branch": null, - "revision": "25d8170c31173c7db4ddfef473e257c3bde60783", - "version": "3.30.0" - } - }, - { - "package": "swift-algorithms", - "repositoryURL": "https://github.com/apple/swift-algorithms.git", - "state": { - "branch": null, - "revision": "f6919dfc309e7f1b56224378b11e28bab5bccc42", - "version": "1.2.0" - } - }, - { - "package": "swift-atomics", - "repositoryURL": "https://github.com/apple/swift-atomics.git", - "state": { - "branch": null, - "revision": "cd142fd2f64be2100422d658e7411e39489da985", - "version": "1.2.0" - } - }, - { - "package": "swift-collections", - "repositoryURL": "https://github.com/apple/swift-collections.git", - "state": { - "branch": null, - "revision": "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version": "1.1.0" - } - }, - { - "package": "swift-crypto", - "repositoryURL": "https://github.com/apple/swift-crypto.git", - "state": { - "branch": null, - "revision": "bc1c29221f6dfeb0ebbfbc98eb95cd3d4967868e", - "version": "3.4.0" - } - }, - { - "package": "swift-http-types", - "repositoryURL": "https://github.com/apple/swift-http-types", - "state": { - "branch": null, - "revision": "9bee2fdb79cc740081abd8ebd80738063d632286", - "version": "1.1.0" - } - }, - { - "package": "swift-log", - "repositoryURL": "https://github.com/apple/swift-log.git", - "state": { - "branch": null, - "revision": "e97a6fcb1ab07462881ac165fdbb37f067e205d5", - "version": "1.5.4" - } - }, - { - "package": "swift-metrics", - "repositoryURL": "https://github.com/apple/swift-metrics.git", - "state": { - "branch": null, - "revision": "ce594e71e92a1610015017f83f402894df540e51", - "version": "2.4.4" - } - }, - { - "package": "swift-nio", - "repositoryURL": "https://github.com/apple/swift-nio.git", - "state": { - "branch": null, - "revision": "9428f62793696d9a0cc1f26a63f63bb31da0516d", - "version": "2.66.0" - } - }, - { - "package": "swift-nio-extras", - "repositoryURL": "https://github.com/apple/swift-nio-extras.git", - "state": { - "branch": null, - "revision": "a3b640d7dc567225db7c94386a6e71aded1bfa63", - "version": "1.22.0" - } - }, - { - "package": "swift-nio-http2", - "repositoryURL": "https://github.com/apple/swift-nio-http2.git", - "state": { - "branch": null, - "revision": "8d8eb609929aee75336a0a3d2417280786265868", - "version": "1.32.0" - } - }, - { - "package": "swift-nio-ssl", - "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", - "state": { - "branch": null, - "revision": "2b09805797f21c380f7dc9bedaab3157c5508efb", - "version": "2.27.0" - } - }, - { - "package": "swift-nio-transport-services", - "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", - "state": { - "branch": null, - "revision": "38ac8221dd20674682148d6451367f89c2652980", - "version": "1.21.0" - } - }, - { - "package": "swift-numerics", - "repositoryURL": "https://github.com/apple/swift-numerics", - "state": { - "branch": null, - "revision": "0a5bc04095a675662cf24757cc0640aa2204253b", - "version": "1.0.2" - } - }, - { - "package": "swift-system", - "repositoryURL": "https://github.com/apple/swift-system.git", - "state": { - "branch": null, - "revision": "f9266c85189c2751589a50ea5aec72799797e471", - "version": "1.3.0" - } - }, - { - "package": "vapor", - "repositoryURL": "https://github.com/vapor/vapor.git", - "state": { - "branch": null, - "revision": "12e9b41cc576165150cb236676fc94d997d3db5f", - "version": "4.101.1" - } - }, - { - "package": "websocket-kit", - "repositoryURL": "https://github.com/vapor/websocket-kit.git", - "state": { - "branch": null, - "revision": "4232d34efa49f633ba61afde365d3896fc7f8740", - "version": "2.15.0" - } - } - ] - }, - "version": 1 + "originHash" : "e9df32a7ed0111800f0194bf8b2eed26c6e997fad7b2219ec5f724a29bdb3844", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "5dd84c7bb48b348751d7bbe7ba94a17bafdcef37", + "version" : "1.30.2" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", + "version" : "1.21.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", + "version" : "4.15.2" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "0272fdaf7cf6f482c2799026c0695f5fe40e3e8c", + "version" : "1.53.0" + } + }, + { + "identity" : "fluent-sqlite-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-sqlite-driver.git", + "state" : { + "revision" : "73529a63ab11c7fe87da17b5a67a1b1f58c020f8", + "version" : "4.8.1" + } + }, + { + "identity" : "graphiti", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GraphQLSwift/Graphiti.git", + "state" : { + "revision" : "a23a3d232df202fc158ad2d698926325b470523c", + "version" : "3.0.0" + } + }, + { + "identity" : "graphql", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GraphQLSwift/GraphQL.git", + "state" : { + "revision" : "6e483aec1a8f86f7fee95b1f72e678bdf0e537a4", + "version" : "4.0.3" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "c0ea243ffeb8b5ff9e20a281e44003c6abb8896f", + "version" : "3.34.0" + } + }, + { + "identity" : "sqlite-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-kit.git", + "state" : { + "revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f", + "version" : "4.5.2" + } + }, + { + "identity" : "sqlite-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sqlite-nio.git", + "state" : { + "revision" : "2ab61385b70da8ed74958ce62fa9ebf0359cb08b", + "version" : "1.12.2" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", + "version" : "1.17.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version" : "2.7.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "a1605a3303a28e14d822dec8aaa53da8a9490461", + "version" : "2.92.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "1c90641b02b6ab47c6d0db2063a12198b04e83e2", + "version" : "1.31.2" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", + "version" : "1.39.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", + "version" : "1.6.3" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "f7090db27390ebc4cadbff06d76fe8ce79d6ece6", + "version" : "4.120.0" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", + "version" : "2.16.1" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index 57a66fc..f423dea 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.2 +// swift-tools-version:6.0 import PackageDescription @@ -10,26 +10,41 @@ let package = Package( products: [ .library( name: "GraphQLKit", - targets: ["GraphQLKit"]), + targets: ["GraphQLKit"] + ), ], dependencies: [ - .package(url: "https://github.com/GraphQLSwift/Graphiti.git", from: "1.0.0"), + .package(url: "https://github.com/GraphQLSwift/Graphiti.git", from: "3.0.0"), .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https://github.com/vapor/fluent.git", from: "4.2.0"), + .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"), ], targets: [ - .target(name: "GraphQLKit", - dependencies: [ - .product(name: "Vapor", package: "vapor"), - .product(name: "Graphiti", package: "Graphiti"), - .product(name: "Fluent", package: "fluent") - ] + .target( + name: "GraphQLKit", + dependencies: [ + .product(name: "Vapor", package: "vapor"), + .product(name: "Graphiti", package: "Graphiti"), + .product(name: "Fluent", package: "fluent"), + ] ), - .testTarget(name: "GraphQLKitTests", - dependencies: [ - .target(name: "GraphQLKit"), - .product(name: "XCTVapor", package: "vapor") - ] + .testTarget( + name: "GraphQLKitTests", + dependencies: [ + .target(name: "GraphQLKit"), + .product(name: "VaporTesting", package: "vapor"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + ] ), ] ) + +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("NonIsolatedNonSendingByDefault"), +] + +for target in package.targets { + var settings = target.swiftSettings ?? [] + settings.append(contentsOf: swiftSettings) + target.swiftSettings = settings +} diff --git a/README.md b/README.md index aa170af..e15a4d0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # GraphQLKit -[![Language](https://img.shields.io/badge/Swift-5.2-brightgreen.svg)](http://swift.org) + +[![Language](https://img.shields.io/badge/Swift-6.0-brightgreen.svg)](http://swift.org) [![Vapor Version](https://img.shields.io/badge/Vapor-4-F6CBCA.svg)](http://vapor.codes) [![build](https://github.com/alexsteinerde/graphql-kit/workflows/build/badge.svg)](https://github.com/alexsteinerde/graphql-kit/actions) - Easy setup of a GraphQL server with Vapor. It uses the GraphQL implementation of [Graphiti](https://github.com/GraphQLSwift/Graphiti). ## Features + - [x] Arguments, operation name and query support - [x] Normal access to the `Request` object as in normal Vapor request handlers - [x] Accept JSON in the body of a POST request as the GraphQL query @@ -16,6 +17,7 @@ Easy setup of a GraphQL server with Vapor. It uses the GraphQL implementation of - [ ] Multi-Resolver support ## Installation + ```Swift import PackageDescription @@ -31,28 +33,27 @@ let package = Package( ``` ## Getting Started + ### Define your schema + This package is setup to accept only `Request` objects as the context object for the schema. This gives the opportunity to access all functionality that Vapor provides, for example authentication, service management and database access. To see an example implementation please have a look at the [`vapor-graphql-template`](https://github.com/alexsteinerde/vapor-graphql-template) repository. This package only provides the needed functions to register an existing GraphQL schema on a Vapor application. To define your schema please refer to the [Graphiti](https://github.com/GraphQLSwift/Graphiti) documentations. But by including this package some other helper functions are exposed: #### Async Resolver -An `EventLoopGroup` parameter is no longer required for async resolvers as the `Request` context object already provides access to it's `EventLoopGroup` attribute `eventLoop`. -```Swift -// Instead of adding an unnecessary parameter -func getAllTodos(store: Request, arguments: NoArguments, _: EventLoopGroup) throws -> EventLoopFuture<[Todo]> { - Todo.query(on: store).all() -} +Asynchronously resolve any requests using async/await -// You don't need to provide the eventLoopGroup parameter even when resolving a future. -func getAllTodos(store: Request, arguments: NoArguments) throws -> EventLoopFuture<[Todo]> { - Todo.query(on: store).all() +```Swift +func getAllTodos(store: Request, arguments: NoArguments) async throws -> [Todo] { + try await Todo.query(on: store).all() } ``` #### Enums -It automatically resolves all cases of an enum if the type conforms to `CaseIterable`. + +It automatically resolves all cases of an enum if the type conforms to `CaseIterable`. + ```swift enum TodoState: String, Codable, CaseIterable { case open @@ -63,7 +64,8 @@ enum TodoState: String, Codable, CaseIterable { Enum(TodoState.self), ``` -#### `Parent`, `Children` and `Siblings` +#### `Parent`, `Children` and `Siblings` + Vapor has the functionality to fetch an objects parent, children or siblings automatically with `@Parent`, `@Children` and `@Siblings` types. To integrate this into GraphQL, GraphQLKit provides extensions to the `Field` type that lets you use the parent, children or siblings property as a keypath. Fetching of those related objects is then done automatically. > :warning: Loading related objects in GraphQL has the [**N+1** problem](https://itnext.io/what-is-the-n-1-problem-in-graphql-dd4921cb3c1a). A solution would be to build a DataLoader package for Swift. But this hasn't been done yet. @@ -71,22 +73,22 @@ Vapor has the functionality to fetch an objects parent, children or siblings aut ```swift final class User: Model { ... - + @Children(for: \.$user) var todos: [Todo] - + ... } final class Todo: Model { ... - + @Parent(key: "user_id") var user: User - + @Siblings(through: TodoTag.self, from: \.$todo, to: \.$tag) public var tags: [Tag] - + ... } ``` @@ -104,6 +106,7 @@ Type(Todo.self) { ``` ### Register the schema on the application + In your `configure.swift` file call the `register(graphQLSchema: Schema, withResolver: YourResolver)` on your `Application` instance. By default this registers the GET and POST endpoints at `/graphql`. But you can also pass the optional parameter `at:` and override the default value. ```Swift @@ -112,7 +115,9 @@ app.register(graphQLSchema: todoSchema, withResolver: TodoAPI()) ``` ## License + This project is released under the MIT license. See [LICENSE](LICENSE) for details. ## Contribution + You can contribute to this project by submitting a detailed issue or by forking this project and sending a pull request. Contributions of any kind are very welcome :) diff --git a/Sources/GraphQLKit/GraphQLError+Debuggabe.swift b/Sources/GraphQLKit/GraphQLError+Debuggabe.swift index 38ce0ba..ca39e94 100644 --- a/Sources/GraphQLKit/GraphQLError+Debuggabe.swift +++ b/Sources/GraphQLKit/GraphQLError+Debuggabe.swift @@ -1,7 +1,7 @@ import GraphQL import Vapor -extension GraphQLError: AbortError { +extension GraphQLError: @retroactive AbortError { public var status: HTTPResponseStatus { return .ok } diff --git a/Sources/GraphQLKit/Graphiti+Fluent.swift b/Sources/GraphQLKit/Graphiti+Fluent.swift index 6c0ab25..2615388 100644 --- a/Sources/GraphQLKit/Graphiti+Fluent.swift +++ b/Sources/GraphQLKit/Graphiti+Fluent.swift @@ -1,98 +1,93 @@ +import Fluent import Graphiti import Vapor -import Fluent // Child Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - - /// Creates a GraphQL field for a one-to-many relationship for Fluent +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { + /// Creates a GraphQL field for a one-to-many relationship for Fluent /// - Parameters: /// - name: Filed name /// - keyPath: KeyPath to the @Children property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == [TypeReference] { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture<[ChildType]> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) in - return type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it. + ) where FieldType == [ChildType] { + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws in + try await type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it } - }, as: [TypeReference].self) + } } } // Parent Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for a one-to-many/one-to-one relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @Parent property - public convenience init( + convenience init( _ name: FieldKey, - with keyPath: KeyPath> - ) where FieldType == TypeReference { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) in - return type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it. + with keyPath: KeyPath> + ) where FieldType: Model { + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws in + return try await type[keyPath: keyPath].get(on: context.db) } - }, as: TypeReference.self) + } } } // Siblings Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for a many-to-many relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @Siblings property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == [TypeReference] { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture<[ToType]> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) in - return type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it. + ) where FieldType == [ToType] { + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws in + return try await type[keyPath: keyPath].query(on: context.db).all() // Get the desired property and make the Fluent database query on it } - }, as: [TypeReference].self) + } } } // OptionalParent Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for an optional one-to-many/one-to-one relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @OptionalParent property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == TypeReference? { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) throws -> EventLoopFuture> in - return type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it. + ) where FieldType == ParentType? { + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws -> ParentType? in + return try await type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it } - }, as: TypeReference?.self) + } } } // OptionalChild Relationship -extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { - +public extension Graphiti.Field where Arguments == NoArguments, Context == Request, ObjectType: Model { /// Creates a GraphQL field for an optional one-to-many/one-to-one relationship for Fluent /// - Parameters: /// - name: Field name /// - keyPath: KeyPath to the @OptionalParent property - public convenience init( + convenience init( _ name: FieldKey, with keyPath: KeyPath> - ) where FieldType == TypeReference? { - self.init(name.description, at: { (type) -> (Request, NoArguments, EventLoopGroup) throws -> EventLoopFuture> in - return { (context: Request, arguments: NoArguments, eventLoop: EventLoopGroup) throws -> EventLoopFuture> in - return type[keyPath: keyPath].get(on: context.db) // Get the desired property and make the Fluent database query on it. + ) where FieldType == ParentType? { + self.init(name.description) { type in + { (context: Request, _: NoArguments) async throws -> ParentType? in + return try await type[keyPath: keyPath].get(on: context.db) } - }, as: TypeReference?.self) + } } } diff --git a/Sources/GraphQLKit/Graphiti+Request.swift b/Sources/GraphQLKit/Graphiti+Request.swift index 84ded5c..0d1156a 100644 --- a/Sources/GraphQLKit/Graphiti+Request.swift +++ b/Sources/GraphQLKit/Graphiti+Request.swift @@ -1,38 +1,66 @@ -import Vapor import Graphiti import GraphQL +import Vapor extension Request { - func resolveByBody(graphQLSchema schema: Schema, with rootAPI: RootType) throws -> Future { - let queryRequest = try self.content - .decode(QueryRequest.self) - return self.resolve(byQueryRequest: queryRequest, graphQLSchema: schema, with: rootAPI) + func resolveByBody( + graphQLSchema schema: Schema, + with rootAPI: RootType + ) async throws -> GraphQLResult { + let queryRequest = try content.decode(QueryRequest.self) + + return try await resolve( + byQueryRequest: queryRequest, + graphQLSchema: schema, + with: rootAPI + ) } - func resolveByQueryParameters(graphQLSchema schema: Schema, with rootAPI: RootType) throws -> Future { - guard let queryString = self.query[String.self, at: "query"] else { throw GraphQLError(GraphQLResolveError.noQueryFound) } - let variables = self.query[String.self, at: "variables"].flatMap { - $0.data(using: .utf8).flatMap { (data) -> [String: Map]? in + func resolveByQueryParameters( + graphQLSchema schema: Schema, + with rootAPI: RootType + ) async throws -> GraphQLResult { + guard let queryString = query[String.self, at: "query"] else { + throw GraphQLError(GraphQLResolveError.noQueryFound) + } + + let variables = query[String.self, at: "variables"].flatMap { + $0.data(using: .utf8).flatMap { data -> [String: Map]? in let decoder = JSONDecoder() - if #available(OSX 10.12, *) { - decoder.dateDecodingStrategy = .iso8601 - } + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode([String: Map]?.self, from: data) } } - let operationName = self.query[String.self, at: "operationName"] - let data = QueryRequest(query: queryString, operationName: operationName, variables: variables) - return resolve(byQueryRequest: data, graphQLSchema: schema, with: rootAPI) + let operationName = query[String.self, at: "operationName"] + + let data = QueryRequest( + query: queryString, + operationName: operationName, + variables: variables + ) + + return try await resolve( + byQueryRequest: data, + graphQLSchema: schema, + with: rootAPI + ) } - private func resolve(byQueryRequest data: QueryRequest, graphQLSchema schema: Schema, with rootAPI: RootType) -> Future { - schema.execute( + private func resolve( + byQueryRequest data: QueryRequest, + graphQLSchema schema: Schema, + with rootAPI: RootType + ) async throws -> GraphQLResult { + nonisolated(unsafe) let schema = schema + + return try await schema.execute( request: data.query, resolver: rootAPI, context: self, - eventLoopGroup: self.eventLoop, variables: data.variables ?? [:], - operationName: data.operationName) + operationName: data.operationName + ) } } diff --git a/Sources/GraphQLKit/Graphiti+Router.swift b/Sources/GraphQLKit/Graphiti+Router.swift index b6a4007..40cf6df 100644 --- a/Sources/GraphQLKit/Graphiti+Router.swift +++ b/Sources/GraphQLKit/Graphiti+Router.swift @@ -1,20 +1,23 @@ -import Vapor import Graphiti import GraphQL +import Vapor -extension RoutesBuilder { - public func register(graphQLSchema schema: Schema, withResolver rootAPI: RootType, at path: PathComponent="graphql", postBodyStreamStrategy: HTTPBodyStreamStrategy = .collect) { - self.on(.POST, path, body: postBodyStreamStrategy) { (request) -> EventLoopFuture in - try request.resolveByBody(graphQLSchema: schema, with: rootAPI) - .flatMap({ - $0.encodeResponse(status: .ok, for: request) - }) +public extension RoutesBuilder { + func register( + graphQLSchema schema: Schema, + withResolver rootAPI: RootType, + at path: PathComponent = "graphql", + postBodyStreamStrategy: HTTPBodyStreamStrategy = .collect + ) { + nonisolated(unsafe) let schema = schema + + on(.POST, path, body: postBodyStreamStrategy) { request async throws -> Response in + try await request.resolveByBody(graphQLSchema: schema, with: rootAPI) + .encodeResponse(status: .ok, for: request) } - self.get(path) { (request) -> EventLoopFuture in - try request.resolveByQueryParameters(graphQLSchema: schema, with: rootAPI) - .flatMap({ - $0.encodeResponse(status: .ok, for: request) - }) + get(path) { request async throws -> Response in + try await request.resolveByQueryParameters(graphQLSchema: schema, with: rootAPI) + .encodeResponse(status: .ok, for: request) } } } @@ -23,16 +26,14 @@ enum GraphQLResolveError: Swift.Error { case noQueryFound } -extension GraphQLResult: Content { - public func encodeResponse(for request: Request) -> EventLoopFuture { - return request.eventLoop.submit { - Response( - status: .ok, - headers: [ - "Content-Type": "application/json" - ], - body: .init(data: try GraphQLJSONEncoder().encode(self)) - ) - } +extension GraphQLResult: @retroactive Content { + public func encodeResponse(for _: Request) throws -> Response { + try Response( + status: .ok, + headers: [ + "Content-Type": "application/json", + ], + body: .init(data: GraphQLJSONEncoder().encode(self)) + ) } } diff --git a/Tests/GraphQLKitTests/GraphQLKitTests.swift b/Tests/GraphQLKitTests/GraphQLKitTests.swift index cb6ee4d..ca8ea24 100644 --- a/Tests/GraphQLKitTests/GraphQLKitTests.swift +++ b/Tests/GraphQLKitTests/GraphQLKitTests.swift @@ -1,47 +1,56 @@ -import XCTest -import Vapor -import XCTVapor @testable import GraphQLKit +import Testing +import Vapor +import VaporTesting -final class GraphQLKitTests: XCTestCase { - struct SomeBearerAuthenticator: BearerAuthenticator { +@Suite +struct GraphQLKitTests { + private func withApp(_ test: (Application) async throws -> Void) async throws { + let app = try await Application.make(.testing) + app.databases.use(.sqlite(.memory), as: .sqlite) + app.migrations.add(CreateUser()) + app.migrations.add(CreateArticle()) + try await app.autoMigrate() + try await test(app) + try await app.asyncShutdown() + } + + struct SomeBearerAuthenticator: AsyncBearerAuthenticator { struct User: Authenticatable {} - func authenticate(bearer: BearerAuthorization, for request: Request) -> EventLoopFuture<()> { + func authenticate(bearer: BearerAuthorization, for request: Request) async throws { // Bearer token should be equal to `token` to pass the auth if bearer.token == "token" { request.auth.login(User()) - return request.eventLoop.makeSucceededFuture(()) } else { - return request.eventLoop.makeFailedFuture(Abort(.unauthorized)) + throw Abort(.unauthorized) } } - func authenticate(request: Request) -> EventLoopFuture<()> { + func authenticate(request: Request) async throws { // Bearer token should be equal to `token` to pass the auth if request.headers.bearerAuthorization?.token == "token" { request.auth.login(User()) - return request.eventLoop.makeSucceededFuture(()) } else { - return request.eventLoop.makeFailedFuture(Abort(.unauthorized)) + throw Abort(.unauthorized) } } } struct Address: Content { - public var number: Int - public var streetName: String - public var additionalStreetName: String? - public var city: String - public var postalCode: String - public var country: String + var number: Int + var streetName: String + var additionalStreetName: String? + var city: String + var postalCode: String + var country: String } struct Person: Content { - public var firstName: String - public var lastName: String - public var age: UInt - public var address: Address + var firstName: String + var lastName: String + var age: UInt + var address: Address } struct ProtectedResolver { @@ -49,7 +58,7 @@ final class GraphQLKitTests: XCTestCase { _ = try store.auth.require(SomeBearerAuthenticator.User.self) return "Hello World" } - + func number(store: Request, _: NoArguments) throws -> Int { _ = try store.auth.require(SomeBearerAuthenticator.User.self) return 42 @@ -57,22 +66,27 @@ final class GraphQLKitTests: XCTestCase { } struct Resolver { - func test(store: Request, _: NoArguments) -> String { + func test(store _: Request, _: NoArguments) -> String { "Hello World" } - - func number(store: Request, _: NoArguments) -> Int { + + func number(store _: Request, _: NoArguments) -> Int { 42 } - func person(store: Request, _: NoArguments) throws -> Person { - return Person(firstName: "John", lastName: "Appleseed", age: 42, address: Address( - number: 767, - streetName: "Fifth Avenue", - city: "New York", - postalCode: "NY 10153", - country: "United States" - )) + func person(store _: Request, _: NoArguments) throws -> Person { + return Person( + firstName: "John", + lastName: "Appleseed", + age: 42, + address: Address( + number: 767, + streetName: "Fifth Avenue", + city: "New York", + postalCode: "NY 10153", + country: "United States" + ) + ) } } @@ -82,7 +96,7 @@ final class GraphQLKitTests: XCTestCase { Field("number", at: ProtectedResolver.number) } } - + let schema = try! Schema { Scalar(UInt.self) @@ -114,254 +128,256 @@ final class GraphQLKitTests: XCTestCase { test } """ - - func testPostEndpoint() throws { + + @Test + func postEndpoint() async throws { let queryRequest = QueryRequest(query: query, operationName: nil, variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json - - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - - func testGetEndpoint() throws { - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in - XCTAssertEqual(res.status, .ok) - var body = res.body - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(body.readString(length: expected.count), expected) + + @Test + func getEndpoint() async throws { + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in + #expect(res.status == .ok) + var body = res.body + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(body.readString(length: expected.count) == expected) + } } } - func testPostOperatinName() throws { + @Test + func postOperationName() async throws { let multiQuery = """ - query World { - test - } - - query Number { - number - } - """ + query World { + test + } + + query Number { + number + } + """ + let queryRequest = QueryRequest(query: multiQuery, operationName: "Number", variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json - - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"number":42}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"number":42}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testProtectedPostEndpoint() throws { + @Test + func protectedPostEndpoint() async throws { let queryRequest = QueryRequest(query: query, operationName: nil, variables: nil) let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - let protected = app.grouped(SomeBearerAuthenticator()) - protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json - - var protectedHeaders = headers - protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .unauthorized) - } - - try app.testable().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + try await withApp { app in + let protected = app.grouped(SomeBearerAuthenticator()) + protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + var protectedHeaders = headers + protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .unauthorized) + } + + try await app.testing().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testProtectedGetEndpoint() throws { - let app = Application(.testing) - defer { app.shutdown() } - - let protected = app.grouped(SomeBearerAuthenticator()) - protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) - - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .authorization, value: "Bearer token") - - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in - XCTAssertEqual(res.status, .unauthorized) - } - - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", headers: headers) { res in - XCTAssertEqual(res.status, .ok) - var body = res.body - let expected = #"{"data":{"test":"Hello World"}}"# - XCTAssertEqual(body.readString(length: expected.count), expected) + @Test + func protectedGetEndpoint() async throws { + try await withApp { app in + let protected = app.grouped(SomeBearerAuthenticator()) + protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) + + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .authorization, value: "Bearer token") + + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in + #expect(res.status == .unauthorized) + } + + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)", headers: headers) { res in + #expect(res.status == .ok) + var body = res.body + let expected = #"{"data":{"test":"Hello World"}}"# + #expect(body.readString(length: expected.count) == expected) + } } } - func testProtectedPostOperatinName() throws { + @Test + func protectedPostOperationName() async throws { let multiQuery = """ - query World { - test - } - - query Number { - number - } - """ - let queryRequest = QueryRequest(query: multiQuery, operationName: "Number", variables: nil) - let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - let protected = app.grouped(SomeBearerAuthenticator()) - protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) - - var body = ByteBufferAllocator().buffer(capacity: 0) - body.writeString(data) + query World { + test + } - var headers = HTTPHeaders() - headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) - headers.contentType = .json + query Number { + number + } + """ - var protectedHeaders = headers - protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") + let queryRequest = QueryRequest(query: multiQuery, operationName: "Number", variables: nil) + let data = String(data: try! JSONEncoder().encode(queryRequest), encoding: .utf8)! - try app.testable().test(.POST, "/graphql", headers: headers, body: body) { res in - XCTAssertEqual(res.status, .unauthorized) - } - - try app.testable().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"number":42}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + try await withApp { app in + let protected = app.grouped(SomeBearerAuthenticator()) + protected.register(graphQLSchema: protectedSchema, withResolver: ProtectedResolver()) + + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeString(data) + + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .contentLength, value: body.readableBytes.description) + headers.contentType = .json + + var protectedHeaders = headers + protectedHeaders.replaceOrAdd(name: .authorization, value: "Bearer token") + + try await app.testing().test(.POST, "/graphql", headers: headers, body: body) { res in + #expect(res.status == .unauthorized) + } + + try await app.testing().test(.POST, "/graphql", headers: protectedHeaders, body: body) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"number":42}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testFieldsOrder() throws { + @Test + func fieldsOrder() async throws { let query1Request = QueryRequest(query: // this query returns fields in arbitrary order - """ - query { - person { - firstName - lastName - age - address { - number - streetName - city - postalCode - country - } - } - } - """, operationName: nil, variables: nil) + """ + query { + person { + firstName + lastName + age + address { + number + streetName + city + postalCode + country + } + } + } + """, operationName: nil, variables: nil) let query2Request = QueryRequest(query: // this query will return all fields in alphabetical order - """ - query { - person { - address { - city - country - number - postalCode - streetName - } - age - firstName - lastName - } - } - """, operationName: nil, variables: nil) + """ + query { + person { + address { + city + country + number + postalCode + streetName + } + age + firstName + lastName + } + } + """, operationName: nil, variables: nil) let data1 = String(data: try! JSONEncoder().encode(query1Request), encoding: .utf8)! let data2 = String(data: try! JSONEncoder().encode(query2Request), encoding: .utf8)! - - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: Resolver()) - - var body1 = ByteBufferAllocator().buffer(capacity: 0) - body1.writeString(data1) - var headers1 = HTTPHeaders() - headers1.replaceOrAdd(name: .contentLength, value: body1.readableBytes.description) - headers1.contentType = .json - var body2 = ByteBufferAllocator().buffer(capacity: 0) - body2.writeString(data2) - var headers2 = HTTPHeaders() - headers2.replaceOrAdd(name: .contentLength, value: body2.readableBytes.description) - headers2.contentType = .json - - try app.testable().test(.POST, "/graphql", headers: headers1, body: body1) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"person":{"firstName":"John","lastName":"Appleseed","age":42,"address":{"number":767,"streetName":"Fifth Avenue","city":"New York","postalCode":"NY 10153","country":"United States"}}}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) - } - - try app.testable().test(.POST, "/graphql", headers: headers2, body: body2) { res in - XCTAssertEqual(res.status, .ok) - var res = res - let expected = #"{"data":{"person":{"address":{"city":"New York","country":"United States","number":767,"postalCode":"NY 10153","streetName":"Fifth Avenue"},"age":42,"firstName":"John","lastName":"Appleseed"}}}"# - XCTAssertEqual(res.body.readString(length: expected.count), expected) + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: Resolver()) + + var body1 = ByteBufferAllocator().buffer(capacity: 0) + body1.writeString(data1) + var headers1 = HTTPHeaders() + headers1.replaceOrAdd(name: .contentLength, value: body1.readableBytes.description) + headers1.contentType = .json + + var body2 = ByteBufferAllocator().buffer(capacity: 0) + body2.writeString(data2) + var headers2 = HTTPHeaders() + headers2.replaceOrAdd(name: .contentLength, value: body2.readableBytes.description) + headers2.contentType = .json + + try await app.testing().test(.POST, "/graphql", headers: headers1, body: body1) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"person":{"firstName":"John","lastName":"Appleseed","age":42,"address":{"number":767,"streetName":"Fifth Avenue","city":"New York","postalCode":"NY 10153","country":"United States"}}}}"# + #expect(res.body.readString(length: expected.count) == expected) + } + + try await app.testing().test(.POST, "/graphql", headers: headers2, body: body2) { res in + #expect(res.status == .ok) + var res = res + let expected = #"{"data":{"person":{"address":{"city":"New York","country":"United States","number":767,"postalCode":"NY 10153","streetName":"Fifth Avenue"},"age":42,"firstName":"John","lastName":"Appleseed"}}}"# + #expect(res.body.readString(length: expected.count) == expected) + } } } - func testEnum() throws { + @Test + func testEnum() async throws { enum TodoState: String, Codable, CaseIterable { case open case done case forLater } - class TestResolver { + final class TestResolver: Sendable { init() {} - func test(store: Request, _: NoArguments) -> TodoState { + func test(store _: Request, _: NoArguments) -> TodoState { .open } } - let schema = try Schema { Enum(TodoState.self) Query { @@ -370,20 +386,185 @@ final class GraphQLKitTests: XCTestCase { } let query = """ - query { - test + query { + test + } + """ + + try await withApp { app in + app.register(graphQLSchema: schema, withResolver: TestResolver()) + try await app.testing().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in + #expect(res.status == .ok) + var body = res.body + let expected = #"{"data":{"test":"open"}}"# + #expect(body.readString(length: expected.count) == expected) } - """ + } + } + + @Test + func createTestArticles() async throws { + let user = User(username: "tester") - let app = Application(.testing) - defer { app.shutdown() } - - app.register(graphQLSchema: schema, withResolver: TestResolver()) - try app.testable().test(.GET, "/graphql?query=\(query.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)") { res in - XCTAssertEqual(res.status, .ok) - var body = res.body - let expected = #"{"data":{"test":"open"}}"# - XCTAssertEqual(body.readString(length: expected.count), expected) + try await withApp { app in + try await user.save(on: app.db) + + let articles = [ + Article(title: "Hello", userID: user.id!), + Article(title: "There", userID: user.id!), + ] + + for article in articles { + try await article.save(on: app.db) + } + + let savedArticles = try await Article.query(on: app.db).all() + + #expect(savedArticles.count == 2) + #expect(savedArticles[1].title == "There") + } + } + + @Test + func createTestUser() async throws { + let user = User(username: "vaporTester") + + try await withApp { app in + try await user.save(on: app.db) + + let users = try await User.query(on: app.db).all() + + #expect(users.count == 1) + #expect(users[0].username == "vaporTester") + } + } + + struct FluentResolver { + func articles( + _ request: Request, + _: NoArguments + ) async throws -> [Article] { + try await Article.query(on: request.db).all() + } + + func users( + _ request: Request, + _: NoArguments + ) async throws -> [User] { + try await User.query(on: request.db).all() + } + } + + let fluentSchema = try! Schema { + Scalar(UUID.self) + + Type(Article.self) { + Field("id", at: \.id) + Field("title", at: \.title) + Field("user", with: \.$user) + } + + Type(User.self) { + Field("id", at: \.id) + Field("username", at: \.username) + Field("articles", with: \.$articles) + } + + Query { + Field("articles", at: FluentResolver.articles) + Field("users", at: FluentResolver.users) + } + } + + @Test + func getArticles() async throws { + let user = User(username: "tester") + + let request = QueryRequest(query: """ + query { + articles { + title + user { + username + } + } + } + """, operationName: nil, variables: nil).query + + let data = try! JSONEncoder().encode(request) + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeData(data) + + try await withApp { app in + try await user.save(on: app.db) + + let articles = [ + Article(title: "Hello", userID: user.id!), + Article(title: "There", userID: user.id!), + ] + + for article in articles { + try await article.save(on: app.db) + } + + app.register(graphQLSchema: fluentSchema, withResolver: FluentResolver()) + + try await app.testing().test( + .GET, + "/graphql?query=\(request.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)" + ) { response in + #expect(response.status == .ok) + let expected = #"{"data":{"articles":[{"title":"Hello","user":{"username":"tester"}},{"title":"There","user":{"username":"tester"}}]}}"# + #expect(response.body.string == expected) + } + } + } + + @Test + func getUsers() async throws { + let users = [ + User(username: "tester"), + ] + + let request = QueryRequest(query: """ + query { + users { + username + articles { + title + } + } + } + """, operationName: nil, variables: nil).query + + let data = try! JSONEncoder().encode(request) + var body = ByteBufferAllocator().buffer(capacity: 0) + body.writeData(data) + + try await withApp { app in + for user in users { + try await user.save(on: app.db) + } + + let articles = [ + Article(title: "Hello", userID: users[0].id!), + Article(title: "There", userID: users[0].id!), + ] + + for article in articles { + try await article.save(on: app.db) + } + + app.register(graphQLSchema: fluentSchema, withResolver: FluentResolver()) + + try await app.testing().test( + .GET, + "/graphql?query=\(request.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)" + ) { response in + #expect(response.status == .ok) + let expected = #"{"data":{"users":[{"username":"tester","articles":[{"title":"Hello"},{"title":"There"}]}]}}"# + #expect(response.body.string == expected) + } } } } diff --git a/Tests/GraphQLKitTests/Utilities.swift b/Tests/GraphQLKitTests/Utilities.swift new file mode 100644 index 0000000..e1d03d6 --- /dev/null +++ b/Tests/GraphQLKitTests/Utilities.swift @@ -0,0 +1,80 @@ +import FluentSQLiteDriver +import Vapor +import VaporTesting + +final class Article: Model, Content, @unchecked Sendable { + static let schema = "articles" + + @ID + var id: UUID? + + @Field(key: "title") + var title: String + + @Parent(key: "user_id") + var user: User + + init() {} + + init( + id: UUID? = nil, + title: String, + userID: User.IDValue + ) { + self.id = id + self.title = title + $user.id = userID + } +} + +struct CreateArticle: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(Article.schema) + .id() + .field("title", .string, .required) + .field("user_id", .uuid, .required, .references("users", "id")) + .unique(on: "id") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(Article.schema).delete() + } +} + +final class User: Model, Content, @unchecked Sendable { + static let schema = "users" + + @ID + var id: UUID? + + @Field(key: "username") + var username: String + + @Children(for: \.$user) + var articles: [Article] + + init() {} + + init( + id: UUID? = nil, + username: String + ) { + self.id = id + self.username = username + } +} + +struct CreateUser: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(User.schema) + .id() + .field("username", .string, .required) + .unique(on: "id") + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(User.schema).delete() + } +}