diff --git a/Gemfile b/Gemfile index f818540a3..ffb456be4 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,8 @@ gem "uk_postcode" gem "postcodes_io" # Use Ruby objects to build reusable markup. A React inspired evolution of the presenter pattern gem "view_component" +# Use the AWS S3 SDK as storage mechanism +gem "aws-sdk-s3" group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console diff --git a/Gemfile.lock b/Gemfile.lock index 0d6e09795..b922225ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,6 +104,22 @@ GEM addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) ast (2.4.2) + aws-eventstream (1.2.0) + aws-partitions (1.550.0) + aws-sdk-core (3.125.5) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.525.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.53.0) + aws-sdk-core (~> 3, >= 3.125.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.111.3) + aws-sdk-core (~> 3, >= 3.125.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.4.0) + aws-eventstream (~> 1, >= 1.0.2) bcrypt (3.1.16) bindex (0.8.1) bootsnap (1.10.2) @@ -180,6 +196,7 @@ GEM responders (>= 2, < 4) iniparse (1.5.0) io-wait (0.2.1) + jmespath (1.5.0) jquery-rails (4.4.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) @@ -424,6 +441,7 @@ PLATFORMS DEPENDENCIES activeadmin! arbre! + aws-sdk-s3 bootsnap (>= 1.4.4) byebug capybara diff --git a/app/services/paas_configuration_service.rb b/app/services/paas_configuration_service.rb index f14ad34df..9d8f1fe73 100644 --- a/app/services/paas_configuration_service.rb +++ b/app/services/paas_configuration_service.rb @@ -31,7 +31,7 @@ private end def read_s3_buckets - return [] unless s3_config_present? + return {} unless s3_config_present? s3_buckets = {} @paas_config[:"aws-s3-bucket"].each do |bucket_config| diff --git a/app/services/storage_service.rb b/app/services/storage_service.rb new file mode 100644 index 000000000..7a65dda82 --- /dev/null +++ b/app/services/storage_service.rb @@ -0,0 +1,68 @@ +class StorageService + attr_reader :configuration + + def initialize(paas_config_service, paas_instance_name) + @paas_config_service = paas_config_service + @paas_instance_name = paas_instance_name.to_sym + @configuration = create_configuration + @client = create_client + end + + def get_file_io(file_name) + file_response = + @client.get_object(bucket: @configuration.bucket_name, key: file_name) + file_response.body + end + + def write_file(file_name, data) + @client.put_object( + body: data, + bucket: @configuration.bucket_name, + key: file_name, + ) + end + +private + + def create_configuration + unless @paas_config_service.config_present? + raise "No PaaS configuration present" + end + unless @paas_config_service.s3_buckets.key?(@paas_instance_name) + raise "#{@paas_instance_name} instance name could not be found" + end + + bucket_config = @paas_config_service.s3_buckets[@paas_instance_name] + StorageConfiguration.new(bucket_config[:credentials]) + end + + def create_client + credentials = + Aws::Credentials.new( + @configuration.access_key_id, + @configuration.secret_access_key, + ) + Aws::S3::Client.new( + region: @configuration.region, + credentials: credentials, + ) + end +end + +class StorageConfiguration + attr_reader :access_key_id, :secret_access_key, :bucket_name, :region + + def initialize(credentials) + @access_key_id = credentials[:aws_access_key_id] + @secret_access_key = credentials[:aws_secret_access_key] + @bucket_name = credentials[:bucket_name] + @region = credentials[:aws_region] + end + + def ==(other) + @access_key_id == other.access_key_id && + @secret_access_key == other.secret_access_key && + @bucket_name == other.bucket_name && + @region == other.region + end +end diff --git a/spec/services/paas_configuration_service_spec.rb b/spec/services/paas_configuration_service_spec.rb index d67b1ad45..500f05f2f 100644 --- a/spec/services/paas_configuration_service_spec.rb +++ b/spec/services/paas_configuration_service_spec.rb @@ -16,6 +16,7 @@ RSpec.describe "PaasConfigurationService" do end it "does not retrieve any S3 bucket configuration" do + expect(subject.s3_buckets).to be_a(Hash) expect(subject.s3_buckets).to be_empty end end @@ -66,6 +67,7 @@ RSpec.describe "PaasConfigurationService" do end it "does not retrieve any S3 bucket configuration" do + expect(subject.s3_buckets).to be_a(Hash) expect(subject.s3_buckets).to be_empty end end diff --git a/spec/services/storage_service_spec.rb b/spec/services/storage_service_spec.rb new file mode 100644 index 000000000..c778c9079 --- /dev/null +++ b/spec/services/storage_service_spec.rb @@ -0,0 +1,98 @@ +require "rails_helper" + +RSpec.describe "S3Service" do + let(:instance_name) { "instance_1" } + let(:bucket_name) { "bucket_1" } + let(:vcap_services) do + <<-JSON + {"aws-s3-bucket": [ + { + "instance_name": "#{instance_name}", + "credentials": { + "aws_access_key_id": "key_id", + "aws_region": "eu-west-2", + "aws_secret_access_key": "secret", + "bucket_name": "#{bucket_name}" + } + } + ]} + JSON + end + + context "when we create an S3 Service with no PaaS Configuration present" do + subject { StorageService.new(PaasConfigurationService.new, "random_instance") } + it "raises an exception" do + expect { subject }.to raise_error(RuntimeError, /No PaaS configuration present/) + end + end + + context "when we create an S3 Service with an unknown instance name" do + subject { StorageService.new(PaasConfigurationService.new, "random_instance") } + before do + allow(ENV).to receive(:[]).with("VCAP_SERVICES").and_return("{}") + end + + it "raises an exception" do + expect { subject }.to raise_error(RuntimeError, /instance name could not be found/) + end + end + + context "when we create an storage service with a valid instance name" do + subject { StorageService.new(PaasConfigurationService.new, instance_name) } + + before do + allow(ENV).to receive(:[]) + allow(ENV).to receive(:[]).with("VCAP_SERVICES").and_return(vcap_services) + end + + it "creates a Storage Configuration" do + expect(subject.configuration).to be_an(StorageConfiguration) + end + + it "sets the expected parameters in the configuration" do + expected_configuration = StorageConfiguration.new( + { + aws_access_key_id: "key_id", + aws_region: "eu-west-2", + aws_secret_access_key: "secret", + bucket_name: bucket_name, + }, + ) + expect(subject.configuration).to eq(expected_configuration) + end + end + + context "when we create an storage service and write a stubbed object" do + subject { StorageService.new(PaasConfigurationService.new, instance_name) } + let(:filename) { "my_file" } + let(:content) { "content" } + let(:s3_client_stub) { Aws::S3::Client.new(stub_responses: true) } + + before do + allow(ENV).to receive(:[]) + allow(ENV).to receive(:[]).with("VCAP_SERVICES").and_return(vcap_services) + allow(Aws::S3::Client).to receive(:new).and_return(s3_client_stub) + end + + it "retrieves the previously written object successfully if it exists" do + s3_client_stub.stub_responses(:get_object, { body: content }) + + data = subject.get_file_io(filename) + expect(data.string).to eq(content) + end + + it "fails when the object does not exist" do + s3_client_stub.stub_responses(:get_object, "NoSuchKey") + + expect { subject.get_file_io("fake_filename") } + .to raise_error(Aws::S3::Errors::NoSuchKey) + end + + it "writes to the storage with the expected parameters" do + expect(s3_client_stub).to receive(:put_object).with(body: content, + bucket: bucket_name, + key: filename) + subject.write_file(filename, content) + end + end +end