Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ClientHelper / SpecHelper improvement with source generation tests #267

Merged
merged 14 commits into from
Feb 7, 2025
1 change: 1 addition & 0 deletions gems/smithy/lib/smithy/welds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ def self.for(service)
end
end
end

59 changes: 41 additions & 18 deletions gems/smithy/spec/interfaces/weld_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,34 +37,57 @@ def post_process(artifacts)
end
end
end

@tmpdir = SpecHelper.generate(['Weather'], :schema)
end
# rubocop:enable Lint/UselessAssignment

after(:all) do
SpecHelper.cleanup(['Weather'], @tmpdir)
end

it 'includes Thor::Actions' do
expect(Class.new(Smithy::Weld).ancestors).to include(Thor::Actions)
end

it 'can pre process the model' do
weld = Weather::Types::Weld.new
expect(weld).to be_a(Struct)
expect(weld.members).to be_empty
get_forecast_output = Weather::Types::GetForecastOutput.new
expect(get_forecast_output.members).to include(:chance_of_welds)
context 'generated client gem' do
include_context 'generated client gem', { fixture: 'weather' }
mullermp marked this conversation as resolved.
Show resolved Hide resolved

it 'can pre process the model' do
weld = Weather::Types::Weld.new
expect(weld).to be_a(Struct)
expect(weld.members).to be_empty
get_forecast_output = Weather::Types::GetForecastOutput.new
expect(get_forecast_output.members).to include(:chance_of_welds)
end

it 'can post process files' do
other_weld = Weather::Types::OtherWeld.new
expect(other_weld).to be_a(Struct)
end

it 'does not apply welds that return false in #for?' do
expect(defined?(Weather::Types::WeldShouldNotExist)).to be nil
expect(defined?(Weather::Types::OtherWeldShouldNotExist)).to be nil
end
end

it 'can post process files' do
other_weld = Weather::Types::OtherWeld.new
expect(other_weld).to be_a(Struct)
context 'generated schema gem' do
it 'pending'
end

it 'does not apply welds that return false in #for?' do
expect(defined?(Weather::Types::WeldShouldNotExist)).to be nil
expect(defined?(Weather::Types::OtherWeldShouldNotExist)).to be nil
context 'source code' do
include_context 'generated client from source', { fixture: 'weather' }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still struggle with this naming. What about "generated in-memory client"


it 'can pre process the model' do
weld = Weather::Types::Weld.new
expect(weld).to be_a(Struct)
expect(weld.members).to be_empty
get_forecast_output = Weather::Types::GetForecastOutput.new
expect(get_forecast_output.members).to include(:chance_of_welds)
end

it 'cannot post process files' do
expect(defined?(Weather::Types::OtherWeld)).to be nil
end

it 'does not apply welds that return false in #for?' do
expect(defined?(Weather::Types::WeldShouldNotExist)).to be nil
expect(defined?(Weather::Types::OtherWeldShouldNotExist)).to be nil
end
end
end
53 changes: 36 additions & 17 deletions gems/smithy/spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
# frozen_string_literal: true

require 'json'
require 'rspec'
require 'tmpdir'
require 'stringio'

require 'smithy'

require_relative 'support/be_in_documentation_matcher'
require_relative 'support/client_helper'
require_relative 'support/generated_client_context'
require_relative 'support/rbs_spy_test'

module SpecHelper
class << self
# @param [Array<String>] modules A list of modules for the generated code.
# For example, `['Company', 'Weather']` would generate code in the
# `Company::Weather` namespace.
# @param [Symbol] type The type of service to generate. For example,
# `:schema`, `:client`, or `:server`.
# @param [Hash] options Additional options to pass to the generator.
# @option options [String] :fixture The name of the fixture to load.
# @return [String] The path to the directory where the generated code was
# written to.
# temporary legacy to make tests pass
def generate(modules, type, options = {})
model = load_model(modules, options)
plan = create_plan(modules, model, type, options)
Expand All @@ -37,10 +29,7 @@ def generate(modules, type, options = {})
raise e
end

# @param [Array<String>] module_names A list of module names from the
# generated code to clean up.
# @param [String] tmpdir The path to the tmp directory where the
# generated code was written to.
# temporary
def cleanup(module_names, tmpdir)
return unless tmpdir

Expand All @@ -56,8 +45,6 @@ def cleanup(module_names, tmpdir)
Object.send(:remove_const, module_names.first)
end

private

def load_model(modules, options)
fixture = options[:fixture] || modules.map(&:underscore).join('/')
model_dir = File.join(File.dirname(__FILE__), 'fixtures', fixture)
Expand All @@ -73,6 +60,38 @@ def create_plan(modules, model, type, options)
}
Smithy::Plan.new(model, type, plan_options)
end

## end temporary

def generate_client_gem(options = {})
plan = ClientHelper.generate(:client, options)
RbsSpyTest.setup(modules, sdk_dir) if ENV.fetch('SMITHY_RUBY_RBS_TEST', false)
mullermp marked this conversation as resolved.
Show resolved Hide resolved
$LOAD_PATH << "#{plan.destination_root}/lib"
require plan.gem_name
RbsSpyTest.setup(plan.module_name, plan.destination_root) if ENV['SMITHY_RUBY_RBS_TEST']
plan
rescue LoadError => e
puts "Error loading gem: #{plan.gem_name}"
raise e
end

def generate_client_from_source(options = {})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from source I still find confusing maybe generate_in_memory_client or something like that.

module_name, source = ClientHelper.source(:client, options)
Object.module_eval(source)
Object.const_get(module_name)
module_name
rescue LoadError => e
puts "Error evaluating source:\n#{source}"
raise e
end

def cleanup_client_gem(plan)
ClientHelper.cleanup_gem(plan.module_name, plan.destination_root)
end

def cleanup_client_source(module_name)
ClientHelper.undefine_module(module_name)
end
end
end

Expand Down
182 changes: 182 additions & 0 deletions gems/smithy/spec/support/client_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# frozen_string_literal: true

require 'tmpdir'

module ClientHelper
class << self
def sample_shapes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this model should work the same way our other smithy models do - we should have a model.smithy + model.json and we load that here.
Its terrible to work in smithy json. Its even worse to work in ruby hashes that are trying to look like json.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The advantage to having the model json in code, prior to generation, is that we can modify contents in the test. For example v3 code has tests like:

        shapes['StructureShape']['members']['String']['locationName'] = 'str'
        expect(json(string: 'abc')).to eq('{"str":"abc"}')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I like that ability - I guess I'm not suggesting that we can't have that - just that where the default comes from is a smithy model rather than an in code hash which is just kinda ugly and super annoying to edit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this is only written once, but yeah we can have a fixture default I suppose, but we need a way to modify it at runtime I think.

{
'smithy.ruby.tests#SampleClient' => {
'type' => 'service',
'operations' => [
{ 'target' => 'smithy.ruby.tests#Operation' }
]
},
'smithy.ruby.tests#Operation' => {
'type' => 'operation',
'input' => { 'target' => 'smithy.ruby.tests#Structure' },
'output' => { 'target' => 'smithy.ruby.tests#Structure' }
},
'smithy.ruby.tests#Enum' => {
'type' => 'enum',
'members' => {
'member' => {
'target' => 'smithy.api#Unit',
'traits' => { 'smithy.api#enumValue' => 'value' }
}
}
},
'smithy.ruby.tests#intEnum' => {
'type' => 'intEnum',
'members' => {
'member' => {
'target' => 'smithy.api#Unit',
'traits' => { 'smithy.api#enumValue' => 1 }
}
}
},
'smithy.ruby.tests#List' => {
'type' => 'list',
'member' => { 'target' => 'smithy.api#String' }
},
'smithy.ruby.tests#Map' => {
'type' => 'map',
'key' => { 'target' => 'smithy.api#String' },
'value' => { 'target' => 'smithy.api#String' }
},
'smithy.ruby.tests#Structure' => {
'type' => 'structure',
'members' => {
'bigDecimal' => { 'target' => 'smithy.api#BigDecimal' },
'bigInteger' => { 'target' => 'smithy.api#BigInteger' },
'blob' => { 'target' => 'smithy.api#Blob' },
'boolean' => { 'target' => 'smithy.api#Boolean' },
'byte' => { 'target' => 'smithy.api#Byte' },
'document' => { 'target' => 'smithy.api#Document' },
'double' => { 'target' => 'smithy.api#Double' },
'enum' => { 'target' => 'smithy.ruby.tests#Enum' },
'float' => { 'target' => 'smithy.api#Float' },
'intEnum' => { 'target' => 'smithy.ruby.tests#intEnum' },
'integer' => { 'target' => 'smithy.api#Integer' },
'list' => { 'target' => 'smithy.ruby.tests#List' },
'long' => { 'target' => 'smithy.api#Long' },
'map' => { 'target' => 'smithy.ruby.tests#Map' },
'short' => { 'target' => 'smithy.api#Short' },
'string' => { 'target' => 'smithy.api#String' },
'structure' => { 'target' => 'smithy.ruby.tests#Structure' },
'timestamp' => { 'target' => 'smithy.api#Timestamp' },
'union' => { 'target' => 'smithy.ruby.tests#Union' }
}
},
'smithy.ruby.tests#Union' => {
'type' => 'union',
'members' => {
'string' => { 'target' => 'smithy.api#String' }
}
}
}
end

# @param [Array<String>] module_names A list of module names for the
mullermp marked this conversation as resolved.
Show resolved Hide resolved
# generated code. For example, `['Company', 'Weather']` would generate
# code in the `Company::Weather` namespace.
# @param [Symbol] type The type of artifact to generate. For example,
# `:schema` or `:client`.
# @param [Hash] options Additional options to pass to the generator.
# (See Plan#initialize)
# @option options [String] :fixture The name of the fixture to load in the
# fixtures folder relative to this file.
# @option options [Hash] :model The model to generate code for. Defaults to
# a sample model.
# @option options [String] :smithy The smithy version. Defaults to '2.0'.
# @option options [Hash] :shapes The shapes to generate code for. Defaults to
# a sample set of shapes.
# def sample(module_names, type, options = {})
# model = load_model(options)
# plan = create_plan(module_names, model, type, options)
# if options[:fixture]
# sourced_client(plan)
# else
# generated_client(plan)
# end
# end

def generate(type, options = {})
model = load_model(options)
options[:destination_root] ||= Dir.mktmpdir
plan = create_plan(model, type, options)
Smithy.generate(plan)
plan
end

def source(type, options = {})
model = load_model(options)
# options[:module_name] ||= next_sample_module_name
plan = create_plan(model, type, options)
source = Smithy.source(plan)
[plan.module_name, source]
end

def cleanup_gem(module_name, tmpdir = nil)
undefine_module(module_name)
return unless tmpdir

if ENV.fetch('SMITHY_RUBY_KEEP_GENERATED_SOURCE', 'false') == 'true'
mullermp marked this conversation as resolved.
Show resolved Hide resolved
puts "Leaving generated service in: #{tmpdir}"
else
FileUtils.rm_rf(tmpdir)
end
$LOAD_PATH.delete("#{tmpdir}/lib")
end

def undefine_module(module_name)
module_names = module_name.split('::')
module_names.reverse.each_cons(2) do |child, parent|
Object.const_get(parent).send(:remove_const, child)
end
Object.send(:remove_const, module_names.first)
end

private

def create_plan(model, type, options = {})
plan_options = {
gem_version: '0.1.0',
quiet: ENV.fetch('SMITHY_RUBY_QUIET', 'true') == 'true'
}.merge(options)
Smithy::Plan.new(model, type, plan_options)
end

def load_model(options)
return load_fixture(options[:fixture]) if options[:fixture]

options.fetch(:model, model(options))
end

def load_fixture(fixture)
model_dir = File.join(File.dirname(__FILE__), '..', 'fixtures', fixture)
JSON.load_file(File.join(model_dir, 'model.json'))
end

def model(options)
{
'smithy' => smithy(options),
'shapes' => shapes(options)
}
end

def smithy(options)
options[:smithy] || '2.0'
end

def shapes(options)
options[:shapes] || sample_shapes
end

# def next_sample_module_name
# @sample_client_count ||= 0
# @sample_client_count += 1
# "Sample#{@sample_client_count}"
# end
end
end
21 changes: 21 additions & 0 deletions gems/smithy/spec/support/generated_client_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

RSpec.shared_context 'generated client gem' do |options|
before(:all) do
@plan = SpecHelper.generate_client_gem(options)
end

after(:all) do
SpecHelper.cleanup_client_gem(@plan)
end
end

RSpec.shared_context 'generated client from source' do |options|
before(:all) do
@module_name = SpecHelper.generate_client_from_source(options)
end

after(:all) do
SpecHelper.cleanup_client_source(@module_name)
end
end
8 changes: 4 additions & 4 deletions gems/smithy/spec/support/rbs_spy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Utility to set up RBS spy test on generated code.
module RbsSpyTest
class << self
def setup(modules, sdk_dir)
def setup(module_name, sdk_dir)
env = load_rbs_environment(sdk_dir)
tester = RBS::Test::Tester.new(env: env)

Expand All @@ -16,10 +16,10 @@ def setup(modules, sdk_dir)
::RSpec::Mocks::ClassVerifyingDouble
]

spy_modules = [modules.join('::'), 'Smithy::Client']
spy_modules = [module_name, 'Smithy::Client']
spy_classes = []
spy_modules.each do |module_name|
spy_classes += classes_to_spy(Object.const_get(module_name), env)
spy_modules.each do |spy_module_name|
spy_classes += classes_to_spy(Object.const_get(spy_module_name), env)
end

spy_classes.each do |spy_class|
Expand Down
Loading