Skip to content

Commit

Permalink
Merge pull request #127 from d-exclaimation/chore/graphql-http-violation
Browse files Browse the repository at this point in the history
Minor improvement to codebase
  • Loading branch information
d-exclaimation authored Feb 16, 2023
2 parents 0d2cea0 + d3b7989 commit b80eb81
Show file tree
Hide file tree
Showing 14 changed files with 180 additions and 146 deletions.
11 changes: 11 additions & 0 deletions Documentation/pages/docs/features/graphql-over-http.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,14 @@ It should have no impact on legitimate use of your graph except in these two cas
- You implemented and have enabled file uploads through your GraphQL server using `multipart/form-data`.

If either of these apply to you and you want to keep the prevention mechanic, you should configure the relevant clients to send a non-empty `Apollo-Require-Preflight` header along with all requests.


## GraphQL over HTTP spec compliance

As of Pioneer v1, Pioneer is spec compliant with the [GraphQL over HTTP spec](https://github.com/graphql/graphql-http#servers).

### [Details on compliance](https://github.com/graphql/graphql-http/blob/main/implementations/pioneer/README.md)

- **78** audits in total
-**75** pass
- ⚠️ **3** warnings (optional)
11 changes: 7 additions & 4 deletions Documentation/pages/docs/features/graphql-over-websocket.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The newer sub-protocol is [graphql-ws](https://github.com/enisdenjo/graphql-ws).

#### Usage

You can to use this sub-protocol by specifying when initializing Pioneer.
You can to use this sub-protocol by specifying when initializing Pioneer. This is the default option.

```swift {3} showLineNumbers copy
let server = Pioneer(
Expand All @@ -25,20 +25,23 @@ let server = Pioneer(

Even though the sub-protocol is the recommended and default option, there are still some consideration to take account of. Adoption for this sub-protocol are somewhat limited outside the Node.js / Javascript ecosystem or major GraphQL client libraries.

A good amount of other server implementations on many languages have also yet to support this sub-protocol. So, make sure that libraries and frameworks you are using already have support for [graphql-ws](https://github.com/enisdenjo/graphql-ws). If in doubt, it's best to understand how both sub-protocols work and have options to swap between both options.
A good amount of other server implementations on many languages have also yet to support this sub-protocol. So, make sure that libraries and frameworks you are using already have support for [graphql-ws](https://github.com/enisdenjo/graphql-ws).

### `subscriptions-transport-ws`

The older standard is [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws). This is a sub-protocol from the team at Apollo GraphQL, that was created along side [apollo-server](https://github.com/apollographql/apollo-server) and [apollo-client](https://github.com/apollographql/apollo-client). Some clients and servers still use this to perform operations through websocket especially subscriptions.

<Callout type="warning">
In the GraphQL ecosystem, subscriptions-transport-ws is considered a legacy protocol.
In the GraphQL ecosystem, subscriptions-transport-ws is considered a legacy protocol and has been archived.

Pioneer now considers this protcol as legacy, marked as deprecated, and will likely be removed in the future major releases.

More explaination [here](#consideration).
</Callout>

#### Usage

By default, Pioneer will already use this sub-protocol to perform GraphQL operations through websocket.
You can to use this sub-protocol by specifying when initializing Pioneer.

```swift {3} showLineNumbers copy
let server = Pioneer(
Expand Down
9 changes: 7 additions & 2 deletions Documentation/pages/docs/v1/migrating.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ app.middleware.use(
server.vaporMiddleware(
context: { req, res in
...
},
},
websocketContext: { req, payload, gql in
...
},
Expand Down Expand Up @@ -166,13 +166,18 @@ Pioneer will now defaults to
- [.sandbox](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer/ide/sandbox) for its [WebSocket Protocol](/docs/features/graphql-over-websocket/#websocket-subprotocol)
- `30` seconds for the keep alive interval for GraphQL over WebSocket

### Deprecating `subscriptions-transport-ws`

As of Mar 4 2022, the [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws) has been made read-only archive and will be marked as deprecated in Pioneer.
Pioneer will now defaults to the [`graphql-ws`](/docs/features/graphql-over-websocket/#websocket-subprotocol) instead.

### WebSocket callbacks

Some WebSocket callbacks are now exposed as functions in Pioneer. These can be used to add a custom WebSocket layer.

- [.receiveMessage](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer)
- Callback to be called for each WebSocket message
- [.initialiseClient](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer)
- [.createClient](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer)
- Callback after getting a GraphQL over WebSocket initialisation message according to the given protocol
- [.executeLongOperation](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer)
- Callback to run long running operation using Pioneer
Expand Down
67 changes: 26 additions & 41 deletions Documentation/pages/docs/web-frameworks/integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,53 +34,30 @@ struct HTTPGraphQLRequest {
}
```

The important part is parsing into [GraphQLRequest](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequest). A recommended approach in parsing is:
The important part is parsing into [GraphQLRequest](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequest).
This can be done by making sure the web-framework request object conforms to the [GraphQLRequestConvertible](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequestconvertible) protocol.

1. Parse [GraphQLRequest](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequest) from the body of a request. (Usually for **POST**)
2. If it's not in the body, get the values from the query/search parameters. (Usually for **GET**)
- The query string should be under `query`
- The operation name should be under `operationName`
- The variables should be under `variables` as JSON string <br/>
(_This is probably percent encoded, and also need to be parse into `[String: Map]?` if available_)
- As long the query string is accessible, the request is not malformed and we can construct a [GraphQLRequest](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequest) using that.
3. If [GraphQLRequest](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequest) can't be retreive by both approach 1 and 2, the request is malformed and the response could also have status code of 400 Bad Request.
After that, the [GraphQLRequest](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequest) can be accessed from the property [.graphql](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlrequestconvertible).

<details>
<summary> Example </summary>

```swift showLineNumbers copy
import class WebFramework.Request

extension Request {
var graphql: HTTPGraphQLRequest? {
switch (method) {
// Parsing from body for POST
case .post:
guard let gql = try? JSONDecoder().decode(GraphQLRequest.self, from: self.body) else {
return nil
}
return .init(request: gql, headers: headers, method: method)
extension Request: GraphQLRequestConvertible {
public func body<T>(_ decodable: T.Type) throws -> T where T: Decodable {
try JSONDecoder().decode(decodable, from: body)
}

// Parsing from query/search params for GET
case .get:
guard let query = self.search["query"] else {
return nil
}
let operationName = self.search["operationName"]
let variables = self.search["variables"]?
.removingPercentEncoding
.flatMap {
$0.data(using: .utf8)
}
.flatMap {
try? JSONDecoder().decode([String: Map].self, from: $0)
}
let gql = GraphQLRequest(query: query, operationName: operationName, variables: variables)
return .init(request: gql, headers: headers, method: method)

default:
return nil
}
public func searchParams<T>(_ decodable: T.Type, at: String) -> T? where T: Decodable {
search[at]?.removingPercentEncoding
.flatMap { $0.data(using: .utf8) }
.flatMap { try? JSONDecoder().decode(decodable, from: $0) }
}

public var isAcceptingGraphQLResponse: Bool {
headers[.accept].contains(HTTPGraphQLRequest.mediaType)
}
}
```
Expand Down Expand Up @@ -119,10 +96,15 @@ struct HTTPGraphQLResponse {
}
```

<Callout type="info">
The property [.graphql](#mapping-into-httpgraphqlrequest) may throw a [GraphQLViolation](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/graphqlviolation) error.
This error should be caught, the its message and status value should be use in the response to comply with the GraphQL over HTTP specification.
</Callout>

<details>
<summary> Example </summary>

```swift {9-14,16-19,23-25} showLineNumbers copy
```swift {9-14,16-19,23-24,26-28} showLineNumbers copy
import class WebFramework.Request
import class WebFramework.Response
import struct Pioneer.Pioneer
Expand All @@ -144,6 +126,9 @@ extension Pioneer {
res.status = httpRes.status

return res
} catch let e as GraphQLViolation {
let body = try GraphQLJSONEncoder().encode(GraphQLResult(data: nil, errors: [.init(e.message)]))
return Response(status: e.status(req.isAcceptingGraphQLResponse), body: body)
} catch {
// Format error caught into GraphQLResult
let body = try GraphQLJSONEncoder().encode(GraphQLResult(data: nil, errors: [.init(error)]))
Expand Down Expand Up @@ -288,7 +273,7 @@ After the upgrade is done, there's only a few things to do:
- [.receiveMessage](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer) method is used here.
- For consuming the incoming message, if in the web-framework it is done in a callback, it is best to pipe that value into an AsyncStream first and iterate through the AsyncStream before calling the [.receiveMessage](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer) method.
- Setting up callback for when the connection has been closed.
- [.closeClient](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer) method is used here.
- [.disposeClient](https://swiftpackageindex.com/d-exclaimation/pioneer/documentation/pioneer/pioneer) method is used here.
- It is also recommended if possible to stop the consuming incoming message here as well.

<details>
Expand Down Expand Up @@ -356,7 +341,7 @@ extension Pioneer {
Task {
try await ws.onClose.get()
receiving.cancel()
closeClient(cid: cid, keepAlive: keepAlive, timeout: timeout)
disposeClient(cid: cid, keepAlive: keepAlive, timeout: timeout)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2022 d-exclaimation
Copyright 2023 d-exclaimation

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
12 changes: 3 additions & 9 deletions Sources/Pioneer/GraphQL/GraphQLRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// Created by d-exclaimation on 12:49 AM.
//

import Foundation
import GraphQL
import enum NIOHTTP1.HTTPResponseStatus

/// GraphQL Request according to the spec
public struct GraphQLRequest: Codable, @unchecked Sendable {
Expand Down Expand Up @@ -34,7 +34,7 @@ public struct GraphQLRequest: Codable, @unchecked Sendable {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: Key.self)
guard container.contains(.query) else {
throw ParsingIssue.missingQuery
throw GraphQLViolation.missingQuery
}
do {
let query = try container.decode(String.self, forKey: .query)
Expand All @@ -48,7 +48,7 @@ public struct GraphQLRequest: Codable, @unchecked Sendable {
extensions: extensions ?? nil
)
} catch {
throw ParsingIssue.invalidForm
throw GraphQLViolation.invalidForm
}
}

Expand Down Expand Up @@ -103,10 +103,4 @@ public struct GraphQLRequest: Codable, @unchecked Sendable {
}
}
}

/// Known possible failure in parsing GraphQLRequest
public enum ParsingIssue: Error, Sendable {
case missingQuery
case invalidForm
}
}
68 changes: 68 additions & 0 deletions Sources/Pioneer/GraphQL/GraphQLViolation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// GraphQLViolation.swift
// pioneer
//
// Created by d-exclaimation on 20:04.
//

import enum NIOHTTP1.HTTPResponseStatus

/// Violation to the GraphQL over HTTP spec
public struct GraphQLViolation: Error, Sendable, Equatable {
/// Different HTTP status codes for different media type as per GraphQL over HTTP spec
public struct ResponseStatuses: Sendable, Equatable {
/// Status for application/json
public var json: HTTPResponseStatus
/// Status for application/graphql-response+json
public var graphql: HTTPResponseStatus

public init(json: HTTPResponseStatus, graphql: HTTPResponseStatus) {
self.json = json
self.graphql = graphql
}
}

/// Default message for this error
public var message: String
/// Appopriate HTTP status code for this error as per GraphQL over HTTP spec
public var status: ResponseStatuses

public init(message: String, status: HTTPResponseStatus) {
self.message = message
self.status = .init(json: status, graphql: status)
}

public init(message: String, status: ResponseStatuses) {
self.message = message
self.status = status
}

/// Get the appropriate HTTP status code for the media type
/// - Parameter isAcceptingGraphQLResponse: If the accept media type is application/graphql-response+json
/// - Returns: HTTP status code
public func status(_ isAcceptingGraphQLResponse: Bool) -> HTTPResponseStatus {
isAcceptingGraphQLResponse ? status.graphql : status.json
}

static var missingQuery: Self {
.init(
message: "Missing query in request",
status: .init(json: .ok, graphql: .badRequest)
)
}

static var invalidForm: Self {
.init(
message: "Invalid GraphQL request form",
status: .init(json: .ok, graphql: .badRequest)
)
}

static var invalidMethod: Self {
.init(message: "Invalid HTTP method for a GraphQL request", status: .badRequest)
}

static var invalidContentType: Self {
.init(message: "Invalid or missing content-type", status: .badRequest)
}
}
20 changes: 7 additions & 13 deletions Sources/Pioneer/Http/HTTPGraphQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,6 @@ public struct HTTPGraphQLRequest: Sendable {

/// GraphQL over HTTP spec's content type
public static var contentType = "\(mediaType); charset=utf-8, \(mediaType)"

/// Known possible failure in converting HTTP into GraphQL over HTTP request
public enum Issue: Error, Sendable {
case invalidMethod
case invalidContentType
}
}

/// A type that can be transformed into GraphQLRequest and HTTPGraphQLRequest
Expand All @@ -101,7 +95,7 @@ public protocol GraphQLRequestConvertible {
/// - decodable: Decodable type
/// - at: Name of field to decode
/// - Returns: The parsed payload if possible, otherwise nil
func urlQuery<T: Decodable>(_ decodable: T.Type, at: String) -> T?
func searchParams<T: Decodable>(_ decodable: T.Type, at: String) -> T?
}

public extension GraphQLRequestConvertible {
Expand All @@ -110,20 +104,20 @@ public extension GraphQLRequestConvertible {
get throws {
switch method {
case .GET:
guard let query = urlQuery(String.self, at: "query") else {
throw GraphQLRequest.ParsingIssue.missingQuery
guard let query = searchParams(String.self, at: "query") else {
throw GraphQLViolation.missingQuery
}
let variables: [String: Map]? = self.urlQuery(String.self, at: "variables")
let variables: [String: Map]? = self.searchParams(String.self, at: "variables")
.flatMap { $0.data(using: .utf8)?.to([String: Map].self) }
let operationName: String? = self.urlQuery(String.self, at: "operationName")
let operationName: String? = self.searchParams(String.self, at: "operationName")
return GraphQLRequest(query: query, operationName: operationName, variables: variables)
case .POST:
guard !headers[.contentType].isEmpty else {
throw HTTPGraphQLRequest.Issue.invalidContentType
throw GraphQLViolation.invalidContentType
}
return try body(GraphQLRequest.self)
default:
throw HTTPGraphQLRequest.Issue.invalidMethod
throw GraphQLViolation.invalidMethod
}
}
}
Expand Down
Loading

0 comments on commit b80eb81

Please sign in to comment.