Skip to content

Commit 6dfe2de

Browse files
committed
Merge branch 'feature/OpenSourcePrep' into 'master'
Open Source Prep See merge request PassiveLogic/platform/graphqlws!4
2 parents 7b27770 + cb30ff1 commit 6dfe2de

File tree

9 files changed

+170
-44
lines changed

9 files changed

+170
-44
lines changed

README.md

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,136 @@
11
# GraphQLWS
22

33
This implements the [graphql-ws WebSocket subprotocol](https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md).
4-
It is mainly intended for Server support, but there is a basic client implementation included.
4+
It is mainly intended for server support, but there is a basic client implementation included.
55

66
Features:
77
- Server implementation that implements defined protocol conversations
88
- Client and Server types that wrap messengers
99
- Codable Server and Client message structures
10+
- Custom authentication support
11+
12+
## Usage
13+
14+
To use this package, include it in your `Package.swift` dependencies:
15+
16+
```swift
17+
.package(url: "git@gitlab.com:PassiveLogic/platform/GraphQLWS.git", from: "<version>"),
18+
```
19+
20+
Then create a class to implement the `Messenger` protocol. Here's an example using
21+
[`WebSocketKit`](https://github.com/vapor/websocket-kit):
22+
23+
```swift
24+
import WebSocketKit
25+
import GraphQLWS
26+
27+
/// Messenger wrapper for WebSockets
28+
class WebSocketMessenger: Messenger {
29+
private weak var websocket: WebSocket?
30+
private var onRecieve: (String) -> Void = { _ in }
31+
32+
init(websocket: WebSocket) {
33+
self.websocket = websocket
34+
websocket.onText { _, message in
35+
self.onRecieve(message)
36+
}
37+
}
38+
39+
func send<S>(_ message: S) where S: Collection, S.Element == Character {
40+
guard let websocket = websocket else { return }
41+
websocket.send(message)
42+
}
43+
44+
func onRecieve(callback: @escaping (String) -> Void) {
45+
self.onRecieve = callback
46+
}
47+
48+
func error(_ message: String, code: Int) {
49+
guard let websocket = websocket else { return }
50+
websocket.send("\(code): \(message)")
51+
}
52+
53+
func close() {
54+
guard let websocket = websocket else { return }
55+
_ = websocket.close()
56+
}
57+
}
58+
```
59+
60+
Next create a `Server`, provide the messenger you just defined, and wrap the API `execute` and `subscribe` commands:
61+
62+
```swift
63+
routes.webSocket(
64+
"graphqlSubscribe",
65+
onUpgrade: { request, websocket in
66+
let messenger = WebSocketMessenger(websocket: websocket)
67+
let server = GraphQLWS.Server<EmptyInitPayload?>(
68+
messenger: messenger,
69+
onExecute: { graphQLRequest in
70+
api.execute(
71+
request: graphQLRequest.query,
72+
context: context,
73+
on: self.eventLoop,
74+
variables: graphQLRequest.variables,
75+
operationName: graphQLRequest.operationName
76+
)
77+
},
78+
onSubscribe: { graphQLRequest in
79+
api.subscribe(
80+
request: graphQLRequest.query,
81+
context: context,
82+
on: self.eventLoop,
83+
variables: graphQLRequest.variables,
84+
operationName: graphQLRequest.operationName
85+
)
86+
}
87+
)
88+
}
89+
)
90+
```
91+
92+
### Authentication
93+
94+
This package exposes authentication hooks on the `connection_init` message. To perform custom authentication,
95+
provide a codable type to the Server init and define an `auth` callback on the server. For example:
96+
97+
```swift
98+
struct UsernameAndPasswordInitPayload: Equatable & Codable {
99+
let username: String
100+
let password: String
101+
}
102+
103+
let server = GraphQLWS.Server<UsernameAndPasswordInitPayload>(
104+
messenger: messenger,
105+
onExecute: { ... },
106+
onSubscribe: { ... }
107+
)
108+
server.auth { payload in
109+
guard payload.username == "admin" else {
110+
throw Abort(.unauthorized)
111+
}
112+
}
113+
```
114+
115+
This example would require `connection_init` message from the client to look like this:
116+
117+
```json
118+
{
119+
"type": "connection_init",
120+
"payload": {
121+
"username": "admin",
122+
"password": "supersafe"
123+
}
124+
}
125+
```
126+
127+
If the `payload` field is not required on your server, you may make Server's generic declaration optional like `Server<Payload?>`
10128

11129
## Memory Management
12130

13-
Memory ownership among the Server, Client, and Messager may seem a little backwards. This is because the Swift/Vapor WebSocket
131+
Memory ownership among the Server, Client, and Messenger may seem a little backwards. This is because the Swift/Vapor WebSocket
14132
implementation persists WebSocket objects long after their callback and they are expected to retain strong memory references to the
15-
objects required for responses. In order to align cleanly and avoid memory cycles, Server and Client are injected strongly into Messager
16-
callbacks, and only hold weak references to their Messager. This means that Messager objects (or their enclosing WebSocket) must
17-
be persisted to have the connected Server or Client objects function. That is, if a Server's Messager falls out of scope and deinitializes,
133+
objects required for responses. In order to align cleanly and avoid memory cycles, Server and Client are injected strongly into Messenger
134+
callbacks, and only hold weak references to their Messenger. This means that Messenger objects (or their enclosing WebSocket) must
135+
be persisted to have the connected Server or Client objects function. That is, if a Server's Messenger falls out of scope and deinitializes,
18136
the Server will no longer respond to messages.

Sources/GraphQLWS/Client.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Foundation
44
import GraphQL
55

66
/// Client is an open-ended implementation of the client side of the protocol. It parses and adds callbacks for each type of server respose.
7-
public class Client {
7+
public class Client<InitPayload: Equatable & Codable> {
88
// We keep this weak because we strongly inject this object into the messenger callback
99
weak var messenger: Messenger?
1010

@@ -136,7 +136,7 @@ public class Client {
136136
}
137137

138138
/// Send a `connection_init` request through the messenger
139-
public func sendConnectionInit(payload: ConnectionInitAuth?) {
139+
public func sendConnectionInit(payload: InitPayload) {
140140
guard let messenger = messenger else { return }
141141
messenger.send(
142142
ConnectionInitRequest(

Sources/GraphQLWS/InitPayloads.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Contains convenient `connection_init` payloads for users of this package
2+
3+
/// `connection_init` `payload` that is empty
4+
public struct EmptyInitPayload: Equatable & Codable { }
5+
6+
/// `connection_init` `payload` that includes an `authToken` field
7+
public struct TokenInitPayload: Equatable & Codable {
8+
let authToken: String
9+
}

Sources/GraphQLWS/Messenger.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,23 @@
33
import Foundation
44
import NIO
55

6-
/// Protocol for an object that can send and recieve messages
6+
/// Protocol for an object that can send and recieve messages. This allows mocking in tests
77
public protocol Messenger: AnyObject {
88
// AnyObject compliance requires that the implementing object is a class and we can reference it weakly
9+
10+
/// Send a message through this messenger
11+
/// - Parameter message: The message to send
912
func send<S>(_ message: S) -> Void where S: Collection, S.Element == Character
13+
14+
/// Set the callback that should be run when a message is recieved
1015
func onRecieve(callback: @escaping (String) -> Void) -> Void
11-
func onClose(callback: @escaping () -> Void) -> Void
16+
17+
/// Close the messenger
1218
func close() -> Void
19+
20+
/// Indicate that the messenger experienced an error.
21+
/// - Parameters:
22+
/// - message: The message describing the error
23+
/// - code: An error code
1324
func error(_ message: String, code: Int) -> Void
1425
}

Sources/GraphQLWS/Requests.swift

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,9 @@ struct Request: Equatable, JsonEncodable {
2020
}
2121

2222
/// A websocket `connection_init` request from the client to the server
23-
public struct ConnectionInitRequest: Equatable, JsonEncodable {
23+
public struct ConnectionInitRequest<InitPayload: Codable & Equatable>: Equatable, JsonEncodable {
2424
var type = RequestMessageType.GQL_CONNECTION_INIT
25-
public let payload: ConnectionInitAuth?
26-
}
27-
28-
// TODO: Make this structure user-defined
29-
/// Authorization format for a websocket `connection_init` request from the client to the server
30-
public struct ConnectionInitAuth: Equatable, JsonEncodable {
31-
public let authToken: String
32-
33-
public init(authToken: String) {
34-
self.authToken = authToken
35-
}
25+
let payload: InitPayload
3626
}
3727

3828
/// A websocket `start` request from the client to the server

Sources/GraphQLWS/Server.swift

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import NIO
77
import RxSwift
88

99
/// Server implements the server-side portion of the protocol, allowing a few callbacks for customization.
10-
public class Server {
10+
///
11+
/// By default, there are no authorization checks
12+
public class Server<InitPayload: Equatable & Codable> {
1113
// We keep this weak because we strongly inject this object into the messenger callback
1214
weak var messenger: Messenger?
1315

1416
let onExecute: (GraphQLRequest) -> EventLoopFuture<GraphQLResult>
1517
let onSubscribe: (GraphQLRequest) -> EventLoopFuture<SubscriptionResult>
1618

17-
var auth: (ConnectionInitRequest) throws -> Void = { _ in }
19+
var auth: (InitPayload) throws -> Void = { _ in }
1820
var onExit: () -> Void = { }
1921
var onMessage: (String) -> Void = { _ in }
2022

@@ -66,7 +68,7 @@ public class Server {
6668

6769
switch request.type {
6870
case .GQL_CONNECTION_INIT:
69-
guard let connectionInitRequest = try? self.decoder.decode(ConnectionInitRequest.self, from: json) else {
71+
guard let connectionInitRequest = try? self.decoder.decode(ConnectionInitRequest<InitPayload>.self, from: json) else {
7072
self.error(.invalidRequestFormat(messageType: .GQL_CONNECTION_INIT))
7173
return
7274
}
@@ -96,9 +98,9 @@ public class Server {
9698
}
9799

98100
/// Define the callback run during `connection_init` resolution that allows authorization using the `payload`.
99-
/// Throw to indicate that authorization has failed.
101+
/// Throw from this closure to indicate that authorization has failed.
100102
/// - Parameter callback: The callback to assign
101-
public func auth(_ callback: @escaping (ConnectionInitRequest) throws -> Void) {
103+
public func auth(_ callback: @escaping (InitPayload) throws -> Void) {
102104
self.auth = callback
103105
}
104106

@@ -114,14 +116,14 @@ public class Server {
114116
self.onMessage = callback
115117
}
116118

117-
private func onConnectionInit(_ connectionInitRequest: ConnectionInitRequest, _ messenger: Messenger) {
119+
private func onConnectionInit(_ connectionInitRequest: ConnectionInitRequest<InitPayload>, _ messenger: Messenger) {
118120
guard !initialized else {
119121
self.error(.tooManyInitializations())
120122
return
121123
}
122124

123125
do {
124-
try self.auth(connectionInitRequest)
126+
try self.auth(connectionInitRequest.payload)
125127
}
126128
catch {
127129
self.error(.unauthorized())

Tests/GraphQLWSTests/GraphQLWSTests.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,20 @@ import XCTest
1111
class GraphqlWsTests: XCTestCase {
1212
var clientMessenger: TestMessenger!
1313
var serverMessenger: TestMessenger!
14-
var server: Server!
14+
var server: Server<TokenInitPayload>!
1515

1616
override func setUp() {
17+
// Point the client and server at each other
1718
clientMessenger = TestMessenger()
1819
serverMessenger = TestMessenger()
19-
2020
clientMessenger.other = serverMessenger
2121
serverMessenger.other = clientMessenger
2222

2323
let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: 1).next()
2424
let api = TestAPI()
2525
let context = TestContext()
2626

27-
server = Server(
27+
server = Server<TokenInitPayload>(
2828
messenger: serverMessenger,
2929
onExecute: { graphQLRequest in
3030
api.execute(
@@ -48,7 +48,7 @@ class GraphqlWsTests: XCTestCase {
4848
var messages = [String]()
4949
let completeExpectation = XCTestExpectation()
5050

51-
let client = Client(messenger: clientMessenger)
51+
let client = Client<TokenInitPayload>(messenger: clientMessenger)
5252
client.onMessage { message, _ in
5353
messages.append(message)
5454
completeExpectation.fulfill()
@@ -81,14 +81,14 @@ class GraphqlWsTests: XCTestCase {
8181
var messages = [String]()
8282
let completeExpectation = XCTestExpectation()
8383

84-
let client = Client(messenger: clientMessenger)
84+
let client = Client<TokenInitPayload>(messenger: clientMessenger)
8585
client.onMessage { message, _ in
8686
messages.append(message)
8787
completeExpectation.fulfill()
8888
}
8989

9090
client.sendConnectionInit(
91-
payload: ConnectionInitAuth(
91+
payload: TokenInitPayload(
9292
authToken: ""
9393
)
9494
)
@@ -100,14 +100,15 @@ class GraphqlWsTests: XCTestCase {
100100
)
101101
}
102102

103+
/// Test single op message flow works as expected
103104
func testSingleOp() throws {
104105
let id = UUID().description
105106

106107
// Test single-op conversation
107108
var messages = [String]()
108109
let completeExpectation = XCTestExpectation()
109110

110-
let client = Client(messenger: clientMessenger)
111+
let client = Client<TokenInitPayload>(messenger: clientMessenger)
111112

112113
client.onConnectionAck { _, client in
113114
client.sendStart(
@@ -131,7 +132,7 @@ class GraphqlWsTests: XCTestCase {
131132
messages.append(message)
132133
}
133134

134-
client.sendConnectionInit(payload: ConnectionInitAuth(authToken: ""))
135+
client.sendConnectionInit(payload: TokenInitPayload(authToken: ""))
135136

136137
wait(for: [completeExpectation], timeout: 2)
137138
XCTAssertEqual(
@@ -141,6 +142,7 @@ class GraphqlWsTests: XCTestCase {
141142
)
142143
}
143144

145+
/// Test streaming message flow works as expected
144146
func testStreaming() throws {
145147
let id = UUID().description
146148

@@ -151,7 +153,7 @@ class GraphqlWsTests: XCTestCase {
151153
var dataIndex = 1
152154
let dataIndexMax = 3
153155

154-
let client = Client(messenger: clientMessenger)
156+
let client = Client<TokenInitPayload>(messenger: clientMessenger)
155157
client.onConnectionAck { _, client in
156158
client.sendStart(
157159
payload: GraphQLRequest(
@@ -187,7 +189,7 @@ class GraphqlWsTests: XCTestCase {
187189
messages.append(message)
188190
}
189191

190-
client.sendConnectionInit(payload: ConnectionInitAuth(authToken: ""))
192+
client.sendConnectionInit(payload: TokenInitPayload(authToken: ""))
191193

192194
wait(for: [completeExpectation], timeout: 2)
193195
XCTAssertEqual(

0 commit comments

Comments
 (0)