Skip to content

Commit

Permalink
Implement protocol plugin and rpcv2 protocol (#264)
Browse files Browse the repository at this point in the history
  • Loading branch information
jterapin authored Feb 22, 2025
1 parent a3144ca commit 9bb9460
Show file tree
Hide file tree
Showing 45 changed files with 920 additions and 265 deletions.
57 changes: 42 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,9 @@ For previous pre-release, Java based Smithy-Ruby, see: [smithy-ruby/main](https:

[apache-badge]: https://img.shields.io/badge/License-Apache%202.0-blue.svg


## Helpful Commands

Run `smithy` gem tests:
```
bundle exec rake smithy:spec
```

Run `smithy-client` gem tests:
```
bundle exec rake smithy-client:spec
```

### Smithy Build
local build using smithy cli
```
bundle exec smithy build --debug model/weather.smithy
Expand All @@ -34,25 +24,62 @@ export SMITHY_PLUGIN_DIR=build/smithy/source/smithy-ruby
bundle exec smithy-ruby smith client --gem-name weather --gem-version 1.0.0 --destination-root projections/weather <<< $(smithy ast model/weather.smithy)
```

### IRB
IRB on `weather` gem:
```
irb -I projections/weather/lib -I gems/smithy-client/lib -r weather
irb -I projections/weather/lib -I gems/smithy-client/lib -I gems/smithy-model/lib -r weather
```

Create a Weather client:
```
client = Weather::Client.new(endpoint: 'https://example.com')
protocol = Smithy::Client::Protocols::RPCv2.new
client = Weather::Client.new(endpoint: 'https://example.com', protocol: protocol)
client.get_city(city_id: '1')
client.get_current_time
```

### Fixtures

Build a fixture
```
export SMITHY_PLUGIN_DIR=build/smithy/source/smithy-ruby
bundle exec smithy-ruby smith client --gem-name fixture --gem-version 1.0.0 <<< $(cat gems/smithy/spec/fixtures/endpoints/default-values/model.json)
```

Running RBS validations and tests:
Sync and validate fixtures on smithy:
```
bundle exec rake smithy:sync-fixtures
bundle exec rake smithy:validate-fixtures
```

### Running tests on gems

To run tests on smithy gem:
```
bundle exec rake smithy:spec
```

To run tests on smithy-model gem:
```
bundle exec rake smithy-model:spec
```

To run tests on smithy-client gem:
```
bundle exec rake smithy-client:spec
```

To run RBS validation/tests on smithy gem:
```
bundle exec rake smithy-client:rbs
bundle exec rake smithy:rbs
```

To run RBS validation/tests on smithy-model gem:
```
bundle exec rake smithy-model:rbs
```

To run RBS validation/tests on smithy-client gem:
```
bundle exec rake smithy-client:rbs
```
14 changes: 10 additions & 4 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ namespace :smithy do
Dir.glob('gems/smithy/spec/fixtures/**/model.smithy') do |model_path|
out_path = model_path.sub('.smithy', '.json')
config_files = smithy_build_files.map do |file|
" --config #{file}" if model_path.include?(File.dirname(file))
if model_path.include?(File.dirname(file))
FileUtils.touch(file) # https://github.com/smithy-lang/smithy/issues/2537
" --config #{file}"
end
end
sh("smithy ast#{config_files.join(' ')} #{model_path} > #{out_path}")
sh("smithy ast#{config_files.join} #{model_path} > #{out_path}")
end
end

Expand All @@ -77,9 +80,12 @@ namespace :smithy do
Dir.glob('gems/smithy/spec/fixtures/**/model.smithy') do |model_path|
old = JSON.load_file(model_path.sub('.smithy', '.json'))
config_files = smithy_build_files.map do |file|
" --config #{file}" if model_path.include?(File.dirname(file))
if model_path.include?(File.dirname(file))
FileUtils.touch(file) # https://github.com/smithy-lang/smithy/issues/2537
" --config #{file}"
end
end
new = JSON.parse(`smithy ast#{config_files.join(' ')} #{model_path}`)
new = JSON.parse(`smithy ast#{config_files.join} #{model_path}`)
failures << model_path if old != new
end
if failures.any?
Expand Down
5 changes: 3 additions & 2 deletions gems/smithy-client/lib/smithy-client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@
require_relative 'smithy-client/net_http/connection_pool'
require_relative 'smithy-client/net_http/handler'

# codecs
# serde

require_relative 'smithy-client/cbor'
require_relative 'smithy-client/codecs/cbor'
require_relative 'smithy-client/codecs'
require_relative 'smithy-client/protocols'

module Smithy
# Base module for a generated Smithy gem.
Expand Down
70 changes: 52 additions & 18 deletions gems/smithy-client/lib/smithy-client/codecs/cbor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ def serialize(data, shape)
# @param [Struct] type
# @return [Object, Hash]
def deserialize(bytes, shape, type = nil)
return {} if bytes.empty?
return {} if bytes.empty? || shape == Prelude::Unit

parse_data(Client::CBOR.decode(bytes), shape, type)
end

private

def sparse?(shape)
shape.traits.include?('smithy.api#sparse')
end

def format_blob(value)
(value.is_a?(::String) ? value : value.read).force_encoding(Encoding::BINARY)
(value.is_a?(String) ? value : value.read).force_encoding(Encoding::BINARY)
end

def format_data(value, shape)
Expand All @@ -52,20 +56,35 @@ def format_data(value, shape)
end

def format_list(values, shape)
values.collect { |value| format_data(value, shape.member.shape) }
values.collect do |value|
next if value.nil? && !sparse?(shape)

if value.nil? && sparse?(shape)
nil
else
format_data(value, shape.member.shape)
end
end
end

def format_map(values, shape)
values.each.with_object({}) do |(key, value), data|
data[key] = format_data(value, shape.value.shape)
next if value.nil? && !sparse?(shape)

data[key] =
if value.nil? && sparse?(shape)
nil
else
format_data(value, shape.value.shape)
end
end
end

def format_structure(values, shape)
values.each_pair.with_object({}) do |(key, value), data|
if shape.member?(key) && !value.nil?
member = shape.member(key)
data[key] = format_data(value, member.shape)
data[member.name] = format_data(value, member.shape)
end
end
end
Expand All @@ -81,30 +100,45 @@ def parse_data(value, shape, type = nil)
end
end

def parse_list(values, shape, target = nil)
target = [] if target.nil?
def parse_list(values, shape, type = nil)
type = [] if type.nil?
values.each do |value|
target << parse_data(value, shape.member.shape)
next if value.nil? && !sparse?(shape)

type <<
if value.nil?
nil
else
parse_data(value, shape.member.shape)
end
end
target
type
end

def parse_map(values, shape, target = nil)
target = {} if target.nil?
def parse_map(values, shape, type = nil)
type = {} if type.nil?
values.each do |key, value|
target[key] = parse_data(value, shape.value.shape) unless value.nil?
next if value.nil? && !sparse?(shape)

type[key] =
if value.nil?
nil
else
parse_data(value, shape.value.shape)
end
end
target
type
end

def parse_structure(values, shape, target = nil)
target = shape.type.new if target.nil?
def parse_structure(values, shape, type = nil)
type = shape.type.new if type.nil?
values.each do |key, value|
if (member = shape.member(key.to_sym))
target[key] = parse_data(value, member.shape)
if (member = shape.member(key))
member_name = shape.members_by_name[member.name]
type[member_name] = parse_data(value, member.shape)
end
end
target
type
end
end
end
Expand Down
1 change: 1 addition & 0 deletions gems/smithy-client/lib/smithy-client/param_converter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def structure(shape, values)
if values.is_a?(::Struct) || values.is_a?(Hash)
values.each_pair do |k, v|
next if v.nil?

next unless shape.member?(k)

values[k] = member(shape.member(k), v)
Expand Down
28 changes: 28 additions & 0 deletions gems/smithy-client/lib/smithy-client/protocols.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

require_relative 'protocols/rpc_v2'

module Smithy
module Client
# In Smithy, a protocol is a named set of rules that defines the
# syntax and semantics of how a client and server communicate. To
# use a {Smithy::Client}, a protocol is required to be set on
# {Configuration}.
#
# We currently support the following protocols:
#
# - {RPCv2}, a RPC-based protocol over HTTP that sends requests
# and responses with CBOR payloads.
#
# You could also create a custom protocol to pass into the client
# configuration. The given protocol must provide the following
# functionalities:
#
# - `build` - builds the request
# - `parse` - parse the response
# - `error` - extracts the error from response
#
# See {RPCv2} as an example implementation.
module Protocols; end
end
end
82 changes: 82 additions & 0 deletions gems/smithy-client/lib/smithy-client/protocols/rpc_v2.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

module Smithy
module Client
module Protocols
# A RPC-based protocol over HTTP that sends requests
# and responses with CBOR payloads.
#
# TODO: Refactor methods to handle eventstreams
class RPCv2
# @api private
SHAPE_ID = 'smithy.protocols#rpcv2Cbor'

# @param options [Hash] Protocol options
# @option options [Boolean] :query_compatible (nil)
def initialize(options = {})
@query_compatible = options[:query_compatible]
end

# @api private
def build(context)
codec = Client::Codecs::CBOR.new(setting(context))
context.request.body = codec.serialize(context.params, context.operation.input)
context.request.http_method = 'POST'
apply_headers(context)
build_url(context)
end

# @api private
def parse(context)
output_shape = context.operation.output
codec = Client::Codecs::CBOR.new(setting(context))
codec.deserialize(context.response.body.read, output_shape)
end

# @api private
# TODO: To implement after error handling
def error(_context, _response); end

private

def apply_headers(context)
context.request.headers['X-Amzn-Query-Mode'] = 'true' if query_compatible?(context)
context.request.headers['Smithy-Protocol'] = 'rpc-v2-cbor'
apple_content_type(context)
apply_accept_header(context)
# TODO: Implement Content-Length Plugin/Handler
context.request.headers['Content-Length'] = context.request.body.size
end

def apply_accept_header(context)
# TODO: Needs an update when streaming is handled
context.request.headers['Accept'] = 'application/cbor'
end

def apple_content_type(context)
return if context.operation.input == Model::Shapes::Prelude::Unit

# TODO: Needs an update when streaming is handled
context.request.headers['Content-Type'] = 'application/cbor'
end

def build_url(context)
base = context.request.endpoint
base.path +=
"/service/#{context.config.service.name}/operation/#{context.operation.name}"
end

def setting(context)
{}.tap do |h|
h[:query_compatible] = true if query_compatible?(context)
end
end

def query_compatible?(context)
@query_compatible ||
context.config.service.traits.one? { |k, _v| k == 'aws.protocols#awsQuery' }
end
end
end
end
end
6 changes: 6 additions & 0 deletions gems/smithy-client/sig/interfaces.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ module Smithy
end

type endpoint_url = String | URI::HTTP | URI::HTTPS

interface _Protocol
def build: (HandlerContext context) -> void
def parse: (HandlerContext context) -> void
def error: (HandlerContext context, HTTP::Response response) -> void
end
end
end
11 changes: 11 additions & 0 deletions gems/smithy-client/sig/smithy-client/protocols/rpc_v2.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Smithy
module Client
module Protocols
# A RPC-based protocol over HTTP that sends requests
# and responses with CBOR payloads.
class RPCv2
def initialize: (?::Hash[untyped, untyped] options) -> void
end
end
end
end
Loading

0 comments on commit 9bb9460

Please sign in to comment.