Skip to content

Commit

Permalink
Add unary GET protocol support (#203)
Browse files Browse the repository at this point in the history
## Summary

Implements support for Connect unary GET requests per
https://connectrpc.com/docs/protocol#unary-get-request.

By default, this behavior is disabled and can be enabled through an
option on `ProtocolClientConfig` (as demonstrated in the Eliza app).
Behavior is exercised in a new conformance test, but I do intend to add
more unit tests for this after #208.

## Linked issues

- Requires apple/swift-protobuf#1487
- Related to apple/swift-protobuf#1478
- Related to apple/swift-protobuf#1480
- Resolves #196
  • Loading branch information
rebello95 authored Nov 13, 2023
1 parent 3df2d44 commit 5ba072f
Show file tree
Hide file tree
Showing 35 changed files with 529 additions and 95 deletions.
2 changes: 1 addition & 1 deletion Connect-Swift-Mocks.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Pod::Spec.new do |spec|
spec.tvos.deployment_target = '13.0'

spec.dependency 'Connect-Swift', "#{spec.version.to_s}"
spec.dependency 'SwiftProtobuf', '~> 1.24.0'
spec.dependency 'SwiftProtobuf', '~> 1.25.1'

spec.source_files = 'Libraries/ConnectMocks/**/*.swift'

Expand Down
2 changes: 1 addition & 1 deletion Connect-Swift.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Pod::Spec.new do |spec|
spec.osx.deployment_target = '10.15'
spec.tvos.deployment_target = '13.0'

spec.dependency 'SwiftProtobuf', '~> 1.24.0'
spec.dependency 'SwiftProtobuf', '~> 1.25.1'

spec.source_files = 'Libraries/Connect/**/*.swift'

Expand Down
10 changes: 5 additions & 5 deletions Examples/ElizaCocoaPodsApp/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PODS:
- Connect-Swift (0.9.0):
- SwiftProtobuf (~> 1.24.0)
- SwiftProtobuf (1.24.0)
- SwiftProtobuf (~> 1.25.1)
- SwiftProtobuf (1.25.1)

DEPENDENCIES:
- Connect-Swift (from `../..`)
Expand All @@ -15,9 +15,9 @@ EXTERNAL SOURCES:
:path: "../.."

SPEC CHECKSUMS:
Connect-Swift: 385477a717baf939fef2816088c329646ea9c029
SwiftProtobuf: bcfd2bc231cf9ae552cdc7c4e877bd3b41fe57b1
Connect-Swift: 0efd7a4c89656080c6510fe279d8bacaa9f95146
SwiftProtobuf: 69f02cd54fb03201c5e6bf8b76f687c5ef7541a3

PODFILE CHECKSUM: b598f373a6ab5add976b09c2ac79029bf2200d48

COCOAPODS: 1.11.3
COCOAPODS: 1.13.0
3 changes: 2 additions & 1 deletion Examples/ElizaSharedSources/AppSources/MenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ struct MenuView: View {
let config = ProtocolClientConfig(
host: host,
networkProtocol: networkProtocol,
codec: ProtoCodec() // Protobuf binary, or JSONCodec() for JSON
codec: ProtoCodec(), // Protobuf binary, or JSONCodec() for JSON
unaryGET: .disabled // Can enable to use cacheable unary HTTP GET requests
)
#if !COCOAPODS
// For gRPC (which is not supported by CocoaPods), use the NIO HTTP client:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ internal final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1

@available(iOS 13, *)
internal func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Connect.Headers = [:]) async -> ResponseMessage<Connectrpc_Eliza_V1_SayResponse> {
return await self.client.unary(path: "/connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers)
return await self.client.unary(path: "/connectrpc.eliza.v1.ElizaService/Say", idempotencyLevel: .noSideEffects, request: request, headers: headers)
}

@available(iOS 13, *)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "3c54ab05249f59f2c6641dd2920b8358ea9ed127",
"version" : "1.24.0"
"revision" : "07f7f26ded8df9645c072f220378879c4642e063",
"version" : "1.25.1"
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion Examples/buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
version: v1
plugins:
- plugin: buf.build/apple/swift:v1.24.0
- plugin: buf.build/apple/swift:v1.25.1
opt: Visibility=Internal
out: ./ElizaSharedSources/GeneratedSources
- name: connect-swift
Expand Down
26 changes: 20 additions & 6 deletions Libraries/Connect/Implementation/Codecs/JSONCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import Foundation

/// Codec providing functionality for serializing to/from JSON.
public struct JSONCodec: Sendable {
private let encodingOptions: JSONEncodingOptions
private let defaultEncodingOptions: JSONEncodingOptions
private let deterministicEncodingOptions: JSONEncodingOptions
private let decodingOptions: JSONDecodingOptions = {
var options = JSONDecodingOptions()
options.ignoreUnknownFields = true
Expand All @@ -33,10 +34,19 @@ public struct JSONCodec: Sendable {
/// defined in the `.proto` files. By default they are
/// converted to Protobuf's JSON lowerCamelCase format.
public init(alwaysEncodeEnumsAsInts: Bool = false, preserveProtobufFieldNames: Bool = false) {
var encodingOptions = JSONEncodingOptions()
encodingOptions.alwaysPrintEnumsAsInts = alwaysEncodeEnumsAsInts
encodingOptions.preserveProtoFieldNames = preserveProtobufFieldNames
self.encodingOptions = encodingOptions
self.defaultEncodingOptions = {
var encodingOptions = JSONEncodingOptions()
encodingOptions.alwaysPrintEnumsAsInts = alwaysEncodeEnumsAsInts
encodingOptions.preserveProtoFieldNames = preserveProtobufFieldNames
return encodingOptions
}()
self.deterministicEncodingOptions = {
var encodingOptions = JSONEncodingOptions()
encodingOptions.useDeterministicOrdering = true
encodingOptions.alwaysPrintEnumsAsInts = alwaysEncodeEnumsAsInts
encodingOptions.preserveProtoFieldNames = preserveProtobufFieldNames
return encodingOptions
}()
}
}

Expand All @@ -46,7 +56,11 @@ extension JSONCodec: Codec {
}

public func serialize<Input: ProtobufMessage>(message: Input) throws -> Data {
return try message.jsonUTF8Data(options: self.encodingOptions)
return try message.jsonUTF8Data(options: self.defaultEncodingOptions)
}

public func deterministicallySerialize<Input: ProtobufMessage>(message: Input) throws -> Data {
return try message.jsonUTF8Data(options: self.deterministicEncodingOptions)
}

public func deserialize<Output: ProtobufMessage>(source: Data) throws -> Output {
Expand Down
13 changes: 12 additions & 1 deletion Libraries/Connect/Implementation/Codecs/ProtoCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@
// limitations under the License.

import Foundation
import SwiftProtobuf
// TODO: Remove `@preconcurrency` once `SwiftProtobuf.BinaryEncodingOptions` is `Sendable`
@preconcurrency import SwiftProtobuf

/// Codec providing functionality for serializing to/from Protobuf binary.
public struct ProtoCodec {
private let deterministicEncodingOptions: BinaryEncodingOptions = {
var encodingOptions = BinaryEncodingOptions()
encodingOptions.useDeterministicOrdering = true
return encodingOptions
}()

public init() {}
}

Expand All @@ -29,6 +36,10 @@ extension ProtoCodec: Codec {
return try message.serializedData()
}

public func deterministicallySerialize<Input: ProtobufMessage>(message: Input) throws -> Data {
return try message.serializedData(options: self.deterministicEncodingOptions)
}

public func deserialize<Output: ProtobufMessage>(source: Data) throws -> Output {
return try Output(serializedData: source)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ final class ConnectInterceptor: Interceptor {
private let config: ProtocolClientConfig
private let streamResponseHeaders = Locked<Headers?>(nil)

private static let protocolVersion = "1"
fileprivate static let protocolVersion = "1"

init(config: ProtocolClientConfig) {
self.config = config
Expand Down Expand Up @@ -52,12 +52,14 @@ extension ConnectInterceptor: UnaryInterceptor {
finalRequestBody = requestBody
}

proceed(.success(HTTPRequest(
proceed(.success(self.config.transformToGETIfNeeded(HTTPRequest(
url: request.url,
headers: headers,
message: finalRequestBody,
trailers: nil
)))
method: request.method,
trailers: nil,
idempotencyLevel: request.idempotencyLevel
))))
}

@Sendable
Expand Down Expand Up @@ -119,7 +121,9 @@ extension ConnectInterceptor: StreamInterceptor {
url: request.url,
headers: headers,
message: request.message,
trailers: nil
method: request.method,
trailers: nil,
idempotencyLevel: request.idempotencyLevel
)))
}

Expand Down Expand Up @@ -183,3 +187,59 @@ extension ConnectInterceptor: StreamInterceptor {
}
}
}

private extension ProtocolClientConfig {
func transformToGETIfNeeded(_ request: HTTPRequest<Data?>) -> HTTPRequest<Data?> {
guard self.shouldUseUnaryGET(for: request) else {
return request
}

var components = URLComponents(url: request.url, resolvingAgainstBaseURL: true)
components?.queryItems = [
URLQueryItem(name: "base64", value: "1"),
URLQueryItem(
name: "compression",
value: request.headers[HeaderConstants.contentEncoding]?.first
),
URLQueryItem(name: "connect", value: "v\(ConnectInterceptor.protocolVersion)"),
URLQueryItem(name: "encoding", value: self.codec.name()),
URLQueryItem(name: "message", value: request.message?.base64EncodedString()),
]
guard let url = components?.url else {
return request
}

var headers = request.headers
headers.removeValue(forKey: HeaderConstants.contentEncoding)
headers.removeValue(forKey: HeaderConstants.contentType)
headers.removeValue(forKey: HeaderConstants.connectProtocolVersion)

return HTTPRequest(
url: url,
headers: headers,
message: nil,
method: .get,
trailers: nil,
idempotencyLevel: request.idempotencyLevel
)
}
}

extension ProtocolClientConfig {
func shouldUseUnaryGET(for request: HTTPRequest<Data?>) -> Bool {
guard
case .connect = self.networkProtocol, request.idempotencyLevel == .noSideEffects
else {
return false
}

switch self.unaryGET {
case .disabled:
return false
case .alwaysEnabled:
return true
case .enabledForLimitedPayloadSizes(let maxBytes):
return (request.message?.count ?? 0) <= maxBytes
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ extension GRPCWebInterceptor: UnaryInterceptor {
url: request.url,
headers: request.headers.addingGRPCHeaders(using: self.config, grpcWeb: true),
message: envelopedRequestBody,
trailers: nil
method: request.method,
trailers: nil,
idempotencyLevel: request.idempotencyLevel
)))
}

Expand Down Expand Up @@ -125,7 +127,9 @@ extension GRPCWebInterceptor: StreamInterceptor {
url: request.url,
headers: request.headers.addingGRPCHeaders(using: self.config, grpcWeb: true),
message: request.message,
trailers: nil
method: request.method,
trailers: nil,
idempotencyLevel: request.idempotencyLevel
)))
}

Expand Down
47 changes: 34 additions & 13 deletions Libraries/Connect/Implementation/ProtocolClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,45 @@ extension ProtocolClient: ProtocolClientInterface {
@discardableResult
public func unary<Input: ProtobufMessage, Output: ProtobufMessage>(
path: String,
idempotencyLevel: IdempotencyLevel,
request: Input,
headers: Headers,
completion: @escaping @Sendable (ResponseMessage<Output>) -> Void
) -> Cancelable {
let cancelation = Locked<(cancelable: Cancelable?, isCancelled: Bool)>((nil, false))
let codec = self.config.codec
let config = self.config
var headers = headers
headers[HeaderConstants.contentType] = ["application/\(codec.name())"]
headers[HeaderConstants.contentType] = ["application/\(config.codec.name())"]
let request = HTTPRequest<Input>(
url: URL(string: path, relativeTo: URL(string: self.config.host))!,
url: URL(string: path, relativeTo: URL(string: config.host))!,
headers: headers,
message: request,
trailers: nil
method: .post,
trailers: nil,
idempotencyLevel: idempotencyLevel
)
let interceptorChain = self.config.createUnaryInterceptorChain()
interceptorChain.executeLinkedInterceptorsAndStopOnFailure(
interceptorChain.interceptors.map { $0.handleUnaryRequest },
firstInFirstOut: true,
initial: request,
transform: { interceptedRequest, proceed in
transform: { intercepted, proceed in
do {
let data: Data
if config.unaryGET.isEnabled && intercepted.idempotencyLevel == .noSideEffects {
data = try config.codec.deterministicallySerialize(
message: intercepted.message
)
} else {
data = try config.codec.serialize(message: intercepted.message)
}
proceed(.success(HTTPRequest<Data?>(
url: interceptedRequest.url,
headers: interceptedRequest.headers,
message: try codec.serialize(message: interceptedRequest.message),
trailers: interceptedRequest.trailers
url: intercepted.url,
headers: intercepted.headers,
message: data,
method: intercepted.method,
trailers: intercepted.trailers,
idempotencyLevel: intercepted.idempotencyLevel
)))
} catch let error {
proceed(.failure(ConnectError(
Expand Down Expand Up @@ -109,7 +122,7 @@ extension ProtocolClient: ProtocolClientInterface {
initial: interceptedResponse,
transform: { response, proceed in
proceed(ResponseMessage<Output>(
response: response, codec: codec
response: response, codec: config.codec
))
},
then: interceptorChain.interceptors.map { $0.handleUnaryResponse },
Expand Down Expand Up @@ -171,11 +184,15 @@ extension ProtocolClient: ProtocolClientInterface {
@available(iOS 13, *)
public func unary<Input: ProtobufMessage, Output: ProtobufMessage>(
path: String,
idempotencyLevel: IdempotencyLevel,
request: Input,
headers: Headers
) async -> ResponseMessage<Output> {
return await UnaryAsyncWrapper { completion in
self.unary(path: path, request: request, headers: headers, completion: completion)
self.unary(
path: path, idempotencyLevel: idempotencyLevel, request: request,
headers: headers, completion: completion
)
}.send()
}

Expand Down Expand Up @@ -316,7 +333,9 @@ extension ProtocolClient: ProtocolClientInterface {
url: URL(string: path, relativeTo: URL(string: self.config.host))!,
headers: headers,
message: (),
trailers: nil
method: .post,
trailers: nil,
idempotencyLevel: .unknown
)
interceptorChain.executeInterceptorsAndStopOnFailure(
interceptorChain.interceptors.map { $0.handleStreamStart },
Expand All @@ -330,7 +349,9 @@ extension ProtocolClient: ProtocolClientInterface {
url: interceptedRequest.url,
headers: interceptedRequest.headers,
message: nil, // Message is void on stream creation.
trailers: interceptedRequest.trailers
method: interceptedRequest.method,
trailers: interceptedRequest.trailers,
idempotencyLevel: interceptedRequest.idempotencyLevel
),
responseCallbacks: responseCallbacks
))
Expand Down
Loading

0 comments on commit 5ba072f

Please sign in to comment.