├── .travis.yml ├── src ├── aws │ ├── version.cr │ ├── s3 │ │ ├── s3.cr │ │ ├── presigned.cr │ │ ├── object.cr │ │ ├── bucket.cr │ │ ├── responses │ │ │ ├── get_object_output.cr │ │ │ ├── put_object_output.cr │ │ │ ├── upload_part_output.cr │ │ │ ├── start_multipart_upload.cr │ │ │ ├── complete_multipart_upload_output.cr │ │ │ ├── list_all_my_buckets.cr │ │ │ ├── list_objects_v2.cr │ │ │ └── batch_delete_output.cr │ │ ├── presigned │ │ │ ├── post_field.cr │ │ │ ├── html_printer.cr │ │ │ ├── post_policy.cr │ │ │ ├── field_collection.cr │ │ │ ├── url_options.cr │ │ │ ├── form.cr │ │ │ ├── url.cr │ │ │ └── post.cr │ │ ├── paginators │ │ │ └── list_object_v2.cr │ │ ├── file_uploader.cr │ │ ├── multipart_file_uploader.cr │ │ ├── content_type.cr │ │ └── client.cr │ ├── sqs │ │ ├── sqs.cr │ │ └── client.cr │ └── utils │ │ ├── signer_factory.cr │ │ ├── xml.cr │ │ └── http.cr └── aws.cr ├── spec ├── aws_spec.cr ├── spec_helper.cr ├── utils │ ├── signer_factory_spec.cr │ ├── xml_spec.cr │ └── http_spec.cr ├── s3 │ ├── responses │ │ └── complete_multipart_upload_output_spec.cr │ ├── object_spec.cr │ ├── presigned │ │ ├── post_field_spec.cr │ │ ├── post_policy_spec.cr │ │ ├── field_collection_spec.cr │ │ ├── html_printer_spec.cr │ │ ├── url_spec.cr │ │ ├── form_spec.cr │ │ └── post_spec.cr │ ├── bucket_spec.cr │ ├── content_type_spec.cr │ ├── fixtures.cr │ ├── file_uploader_spec.cr │ └── client_spec.cr └── sqs │ └── client_spec.cr ├── CHANGELOG.md ├── .gitignore ├── .editorconfig ├── shard.yml ├── LICENSE └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /src/aws/version.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | VERSION = "0.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/aws_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Aws do 4 | end 5 | -------------------------------------------------------------------------------- /src/aws/s3/s3.cr: -------------------------------------------------------------------------------- 1 | require "./*" 2 | 3 | # AWS S3 access via Crystal. 4 | module Aws::S3 5 | # :nodoc: 6 | SERVICE_NAME = "s3" 7 | end 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.0 (15-07-2019) 2 | 3 | - Crystal 0.29.0 support :100: 4 | 5 | # 0.2.0 (05-11-2018) 6 | 7 | - Crystal 0.26.1 support :heart: -------------------------------------------------------------------------------- /src/aws/sqs/sqs.cr: -------------------------------------------------------------------------------- 1 | require "./*" 2 | 3 | # AWS S3 access via Crystal. 4 | module Aws::Sqs 5 | # :nodoc: 6 | SERVICE_NAME = "sqs" 7 | end 8 | -------------------------------------------------------------------------------- /src/aws.cr: -------------------------------------------------------------------------------- 1 | require "awscr-signer" 2 | require "./aws/*" 3 | require "./aws/utils/*" 4 | require "./aws/sqs/*" 5 | require "./aws/s3/*" 6 | 7 | module Aws 8 | end 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /src/aws/s3/presigned.cr: -------------------------------------------------------------------------------- 1 | require "./presigned/*" 2 | 3 | module Aws 4 | module S3 5 | # Create presigned forms, post objects, and urls. 6 | module Presigned 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "timecop" 3 | require "webmock" 4 | require "./s3/fixtures" 5 | 6 | struct Time 7 | def self.utc_now 8 | Timecop.now 9 | end 10 | end 11 | 12 | Spec.before_each do 13 | WebMock.reset 14 | end 15 | 16 | require "../src/aws" 17 | -------------------------------------------------------------------------------- /src/aws/s3/object.cr: -------------------------------------------------------------------------------- 1 | module Aws::S3 2 | # An object on S3 3 | class Object 4 | # The key of the `Object` 5 | getter key 6 | 7 | # The size of the `Object`, in bytes 8 | getter size 9 | 10 | # The `Object` etag 11 | getter etag 12 | 13 | def initialize(@key : String, @size : Int32, @etag : String) 14 | end 15 | 16 | def_equals @key, @size, @etag 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: aws 2 | version: 0.3.0 3 | 4 | dependencies: 5 | awscr-signer: 6 | github: taylorfinnell/awscr-signer 7 | 8 | development_dependencies: 9 | webmock: 10 | github: manastech/webmock.cr 11 | version: 0.10.0 12 | timecop: 13 | github: waterlink/timecop.cr 14 | version: ~> 0.1.0 15 | 16 | authors: 17 | - Serdar Dogruyol 18 | 19 | crystal: 0.26.1 20 | 21 | license: MIT 22 | -------------------------------------------------------------------------------- /src/aws/s3/bucket.cr: -------------------------------------------------------------------------------- 1 | module Aws::S3 2 | class Bucket 3 | # The name of the bucket 4 | getter name 5 | 6 | # The time the bucket was created 7 | getter creation_time 8 | 9 | # The owner of the bucket 10 | getter owner 11 | 12 | # An S3 Bucket 13 | def initialize(@name : String, @creation_time : Time, @owner : String? = nil) 14 | end 15 | 16 | def_equals @name, @creation_time 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/aws/s3/responses/get_object_output.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Aws::S3::Response 4 | class GetObjectOutput 5 | # The body of the request object 6 | getter body 7 | 8 | # Create a `GetObjectOutput` response from an 9 | # `HTTP::Client::Response` object 10 | def self.from_response(response) 11 | new(response.body) 12 | end 13 | 14 | def initialize(@body : IO | String) 15 | end 16 | 17 | def_equals @body 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/aws/s3/responses/put_object_output.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Aws::S3::Response 4 | class PutObjectOutput 5 | # Create a `PutObjectOutput` response from an 6 | # `HTTP::Client::Response` object 7 | def self.from_response(response) 8 | new(response.headers["ETag"]) 9 | end 10 | 11 | # The etag of the new object 12 | getter etag 13 | 14 | def initialize(@etag : String) 15 | end 16 | 17 | def_equals @etag 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/aws/s3/responses/upload_part_output.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Aws::S3::Response 4 | class UploadPartOutput 5 | # The etag of the uploaded part 6 | getter etag 7 | 8 | # The part number 9 | getter part_number 10 | 11 | # The upload id for the uploaded part 12 | getter upload_id 13 | 14 | def initialize(@etag : String, @part_number : Int32, @upload_id : String) 15 | end 16 | 17 | def_equals @etag, @part_number, @upload_id 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/utils/signer_factory_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws 4 | module Utils 5 | describe SignerFactory do 6 | it "can return v2 signers" do 7 | signer = SignerFactory.get("sqs","region", "key", "secrety", version: :v2) 8 | signer.should be_a(Awscr::Signer::Signers::V2) 9 | end 10 | 11 | it "can return v4 signers" do 12 | signer = SignerFactory.get("sqs", "region", "key", "secrety", version: :v4) 13 | signer.should be_a(Awscr::Signer::Signers::V4) 14 | end 15 | 16 | it "raises on invalid version" do 17 | expect_raises(Exception) do 18 | SignerFactory.get("sqs", "region", "key", "secrety", version: :v1) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/s3/responses/complete_multipart_upload_output_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Response 6 | describe CompleteMultipartUpload do 7 | describe "equality" do 8 | it "is equal if key, location, and etag are equal" do 9 | CompleteMultipartUpload.new("location", "key", "etag").should eq( 10 | CompleteMultipartUpload.new("location", "key", "etag") 11 | ) 12 | end 13 | 14 | it "is not equal if key, location, or etag are diff" do 15 | (CompleteMultipartUpload.new("location", "key", "etag1") == 16 | CompleteMultipartUpload.new("location", "key", "etag")).should be_false 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/post_field.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | module S3 3 | module Presigned 4 | # A field in a `Presigned::Post` 5 | class PostField 6 | include Comparable(PostField) 7 | 8 | # The key of the field 9 | getter key 10 | 11 | # The value of the field 12 | getter value 13 | 14 | def initialize(@key : String, @value : String) 15 | end 16 | 17 | # Serialize the key into the format required for a `Presigned::Post` 18 | def serialize 19 | {@key => @value} 20 | end 21 | 22 | def <=>(field : PostField) 23 | if @key == field.key && @value == field.value 24 | 0 25 | else 26 | -1 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/s3/object_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws::S3 4 | describe Object do 5 | it "is equal to another object if key size and etag are same" do 6 | object = Object.new("test", 123, "etag") 7 | Object.new("test", 123, "etag").should eq(object) 8 | end 9 | 10 | it "not equal to another object key size and etag" do 11 | object = Object.new("test2", 123, "etag") 12 | (Object.new("test", 123, "asd") == object).should eq(false) 13 | end 14 | 15 | it "has key" do 16 | object = Object.new("test", 123, "etag") 17 | object.key.should eq("test") 18 | end 19 | 20 | it "has size" do 21 | object = Object.new("test", 123, "etag") 22 | object.size.should eq(123) 23 | end 24 | 25 | it "has etag" do 26 | object = Object.new("test", 123, "etag") 27 | object.etag.should eq("etag") 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/s3/presigned/post_field_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | describe PostField do 7 | it "can be compared to a field" do 8 | field = PostField.new("key", "test") 9 | field2 = PostField.new("key", "test2") 10 | 11 | (field == field.dup).should be_true 12 | (field2 == field).should be_false 13 | end 14 | 15 | it "has a key" do 16 | field = PostField.new("key", "test") 17 | 18 | field.key.should eq "key" 19 | end 20 | 21 | it "has a value" do 22 | field = PostField.new("key", "test") 23 | 24 | field.value.should eq("test") 25 | end 26 | 27 | it "serializes" do 28 | field = PostField.new("k", "v") 29 | field.serialize.should eq({"k" => "v"}) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/aws/s3/responses/start_multipart_upload.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Aws::S3::Response 4 | class StartMultipartUpload 5 | # Create a `StartMultipartUpload` response from an 6 | # `HTTP::Client::Response` object 7 | def self.from_response(response) 8 | xml = Utils::XML.new(response.body) 9 | 10 | bucket = xml.string("//InitiateMultipartUploadResult/Bucket") 11 | key = xml.string("//InitiateMultipartUploadResult/Key") 12 | upload_id = xml.string("//InitiateMultipartUploadResult/UploadId") 13 | 14 | new(bucket, key, upload_id) 15 | end 16 | 17 | # The key for the object 18 | getter key 19 | 20 | # The bucket for the object 21 | getter bucket 22 | 23 | # The ID of the new object 24 | getter upload_id 25 | 26 | def initialize(@bucket : String, @key : String, @upload_id : String) 27 | end 28 | 29 | def_equals @key, @bucket, @upload_id 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/aws/s3/responses/complete_multipart_upload_output.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Aws::S3::Response 4 | class CompleteMultipartUpload 5 | # Create a `CompleteMultipartUpload` response from an 6 | # `HTTP::Client::Response` object 7 | def self.from_response(response) 8 | xml = Utils::XML.new(response.body) 9 | 10 | location = xml.string("//CompleteMultipartUploadResult/Location") 11 | key = xml.string("//CompleteMultipartUploadResult/Key") 12 | etag = xml.string("//CompleteMultipartUploadResult/ETag") 13 | 14 | new(location, key, etag) 15 | end 16 | 17 | # The key of the uploaded object 18 | getter key 19 | 20 | # The full location of the uploaded object 21 | getter location 22 | 23 | # The etag of the uploaded object 24 | getter etag 25 | 26 | def initialize(@location : String, @key : String, @etag : String) 27 | end 28 | 29 | def_equals @key, @location, @etag 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/s3/bucket_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws::S3 4 | describe Bucket do 5 | it "is equal to another bucket if name and creation time are equal" do 6 | time = Time.utc 7 | bucket = Bucket.new("test", time) 8 | Bucket.new("test", time).should eq(bucket) 9 | end 10 | 11 | it "not equal to another bucket if name and creation time differ" do 12 | time = Time.utc 13 | bucket = Bucket.new("test2", time) 14 | new_bucket_time = Time.utc + 2.minutes 15 | new_bucket = Bucket.new("test", new_bucket_time) 16 | 17 | (new_bucket == bucket).should eq(false) 18 | end 19 | 20 | it "has a name" do 21 | bucket = Bucket.new("name", Time.utc) 22 | 23 | bucket.name.should eq("name") 24 | end 25 | 26 | it "has a creation_time" do 27 | time = Time.utc 28 | bucket = Bucket.new("name", time) 29 | 30 | bucket.creation_time.should eq(time) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/html_printer.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | module S3 3 | module Presigned 4 | # Print a `Presigned::Form` object as RAW HTML. 5 | class HtmlPrinter 6 | def initialize(form : Form) 7 | @form = form 8 | end 9 | 10 | # Return the raw HTML 11 | def to_s(io : IO) 12 | io << print 13 | end 14 | 15 | # Print a `Presigned::Post` object as RAW HTML. 16 | def print 17 | br = "
" 18 | 19 | inputs = @form.fields.map do |field| 20 | <<-INPUT 21 | 22 | INPUT 23 | end 24 | 25 | <<-HTML 26 |
27 | #{inputs.join(br)} 28 | 29 | #{br} 30 | 31 |
32 | HTML 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/s3/content_type_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws::S3 4 | describe ContentType do 5 | describe "when the io isn't a file" do 6 | it "returns the default Content-Type" do 7 | io = IO::Memory.new("document") 8 | ContentType.get(io).should be(ContentType::DEFAULT) 9 | end 10 | end 11 | 12 | describe "when the io is a file" do 13 | it "returns the correct Content-Type" do 14 | ContentType::TYPES.keys.each do |ext| 15 | tempfile = File.tempfile(ext) 16 | file = File.open(tempfile.path) 17 | ContentType.get(file).should be(ContentType::TYPES[ext]) 18 | tempfile.delete 19 | end 20 | end 21 | end 22 | 23 | describe "when the io is a file and the extension is unknown" do 24 | it "returns the default Content-Type" do 25 | tempfile = File.tempfile(".spicy") 26 | file = File.open(tempfile.path) 27 | ContentType.get(file).should be(ContentType::DEFAULT) 28 | tempfile.delete 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/s3/fixtures.cr: -------------------------------------------------------------------------------- 1 | module Fixtures 2 | def self.start_multipart_upload_response(bucket = "bucket", key = "object", upload_id = "FxtGq8otGhDtYJa5VYLpPOBQo2niM2a1YR8wgcwqHJ1F1Djflj339mEfpm7NbYOoIg.6bIPeXl2RB82LuAnUkTQUEz_ReIu2wOwawGc0Z4SLERxoXospqANXDazuDmRF") 3 | <<-RESP 4 | 5 | 6 | #{bucket} 7 | #{key} 8 | #{upload_id} 9 | 10 | RESP 11 | end 12 | 13 | def self.complete_multipart_upload_response 14 | <<-RESP_BODY 15 | 16 | 17 | http://s3.amazonaws.com/screensnapr-development/test 18 | screensnapr-development 19 | test 20 | \"7611c6414e4b58f22ff9f59a2c1767b7-2\" 21 | 22 | RESP_BODY 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/aws/utils/signer_factory.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | module Utils 3 | # Fetches an `Awscr::Signer::Signers` based on the signing version 4 | # requested, and configures it with the region, key and secret. 5 | class SignerFactory 6 | # Fetch and configure a signer based on a version algorithm 7 | def self.get(service_name : String, region : String, aws_access_key : String, 8 | aws_secret_key : String, version : Symbol) 9 | case version 10 | when :v4 11 | Awscr::Signer::Signers::V4.new( 12 | service: service_name, 13 | region: region, 14 | aws_access_key: aws_access_key, 15 | aws_secret_key: aws_secret_key 16 | ) 17 | when :v2 18 | Awscr::Signer::Signers::V2.new( 19 | service: service_name, 20 | region: region, 21 | aws_access_key: aws_access_key, 22 | aws_secret_key: aws_secret_key 23 | ) 24 | else 25 | raise "Unknown signer version: #{version}" 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/aws/s3/paginators/list_object_v2.cr: -------------------------------------------------------------------------------- 1 | module Aws::S3::Paginator 2 | # Paginates a `Response::ListObjectsV2` based on the continuation-token. 3 | class ListObjectsV2 4 | include Iterator(Response::ListObjectsV2) 5 | 6 | @last_output : Response::ListObjectsV2? 7 | @bucket : String 8 | 9 | def initialize(@http : Utils::Http, @params : Hash(String, String)) 10 | @params = @params.reject { |_, v| v.nil? || v.empty? } 11 | @bucket = @params.delete("bucket").as(String) 12 | @last_output = nil 13 | end 14 | 15 | # :nodoc: 16 | def next 17 | return stop if (lo = @last_output) && !lo.truncated? 18 | 19 | if lo = @last_output 20 | @params["continuation-token"] = lo.next_token 21 | end 22 | 23 | @last_output = Response::ListObjectsV2.from_response(next_response) 24 | end 25 | 26 | # :nodoc: 27 | private def next_response 28 | @http.get("/#{@bucket}?#{query_string}") 29 | end 30 | 31 | # :nodoc: 32 | private def query_string 33 | @params.map { |k, v| "#{k}=#{URI.escape(v.to_s)}" }.join("&") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Serdar Dogruyol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/aws/s3/responses/list_all_my_buckets.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | 3 | module Aws::S3::Response 4 | class ListAllMyBuckets 5 | include Enumerable(Bucket) 6 | 7 | # :nodoc: 8 | DATE_FORMAT = "%Y-%M-%dT%H:%M:%S %z" 9 | 10 | # Create a `ListAllMyBuckets` response from an 11 | # `HTTP::Client::Response` object 12 | def self.from_response(response) 13 | xml = Utils::XML.new(response.body) 14 | 15 | owner = xml.string("ListAllMyBucketsResult/Owner/DisplayName") 16 | 17 | buckets = [] of Bucket 18 | xml.array("ListAllMyBucketsResult/Buckets/Bucket") do |bucket| 19 | name = bucket.string("Name") 20 | creation_time = bucket.string("CreationDate") 21 | 22 | # @hack 23 | creation_time = "#{creation_time.split(".")[0]} +00:00" 24 | buckets << Bucket.new(name, Time.parse_utc(creation_time, DATE_FORMAT), 25 | owner) 26 | end 27 | 28 | new(buckets) 29 | end 30 | 31 | # The array of buckets 32 | getter buckets 33 | 34 | def initialize(@buckets : Array(Bucket)) 35 | end 36 | 37 | # Iterate over each bucket in the response 38 | def each(&block) 39 | @buckets.each { |b| yield b } 40 | end 41 | 42 | def_equals @buckets 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/sqs/client_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws 4 | module Sqs 5 | describe Client do 6 | it "allows signer version" do 7 | Client.new("adasd", "adasd", "adad", signer: :v2) 8 | end 9 | 10 | describe "create_queue" do 11 | it "creates a queue" do 12 | WebMock.stub(:get, "http://sqs.eu-west-1.amazonaws.com") 13 | .with(query: {"Action" => "CreateQueue", "QueueName" => "queue", "Version" => "2012-11-05"}) 14 | .to_return(body: "") 15 | 16 | client = Client.new("eu-west-1", "key", "secret") 17 | result = client.create_queue("queue") 18 | 19 | result.should be_true 20 | end 21 | end 22 | 23 | describe "send_message" do 24 | it "sends a message" do 25 | WebMock.stub(:post, "http://sqs.eu-west-1.amazonaws.com/queue") 26 | .with(body: "Action=SendMessage&MessageBody=message&Version=2012-11-05", headers: {"Content-Type" => "application/x-www-form-urlencoded"}) 27 | .to_return(body: "") 28 | 29 | client = Client.new("eu-west-1", "key", "secret") 30 | result = client.send_message("queue", "message") 31 | 32 | result.should be_true 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/aws/utils/xml.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | module Utils 3 | class XML 4 | # :nodoc: 5 | struct NamespacedNode 6 | def initialize(@node : ::XML::Node) 7 | end 8 | 9 | def string(name) 10 | @node.xpath("string(#{build_path(name)})", namespaces).as(String) 11 | end 12 | 13 | def array(query) 14 | @node.xpath(build_path(query), namespaces).as(::XML::NodeSet).each do |node| 15 | yield NamespacedNode.new(node) 16 | end 17 | end 18 | 19 | # :nodoc: 20 | private def build_path(path) 21 | anywhere = false 22 | if path.starts_with?("//") 23 | anywhere = true 24 | path = path[2..-1] 25 | end 26 | 27 | parts = path.split("/").map do |part| 28 | "#{namespace}#{part}" 29 | end 30 | 31 | parts = (["/"] + parts) if anywhere 32 | 33 | (parts).join("/") 34 | end 35 | 36 | # :nodoc: 37 | private def namespace 38 | if namespaces.empty? 39 | "" 40 | else 41 | "#{namespaces.keys.first}:" 42 | end 43 | end 44 | 45 | # :nodoc: 46 | private def namespaces 47 | @node.root.not_nil!.namespaces 48 | end 49 | end 50 | 51 | def initialize(xml : String) 52 | @xml = NamespacedNode.new(::XML.parse(xml)) 53 | end 54 | 55 | # :nodoc: 56 | forward_missing_to @xml 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/post_policy.cr: -------------------------------------------------------------------------------- 1 | require "base64" 2 | require "json" 3 | 4 | module Aws 5 | module S3 6 | module Presigned 7 | class Policy 8 | @expiration : Time? 9 | 10 | # The expiration time of this policy 11 | getter expiration 12 | 13 | # The policy fields 14 | getter fields 15 | 16 | def initialize 17 | @fields = FieldCollection.new 18 | end 19 | 20 | # The expiration time of the `Policy`. 21 | def expiration(time : Time | Nil) 22 | @expiration = time 23 | end 24 | 25 | # Returns true if the `Policy` is valid, false otherwise. 26 | def valid? 27 | # @todo check that the sig keys exist 28 | !!!@expiration.nil? 29 | end 30 | 31 | # Adds a `Condition` to the `Policy`. 32 | def condition(key : String, value : String | Int32) 33 | @fields.push(PostField.new(key, value)) 34 | self 35 | end 36 | 37 | # Returns the hash Representation of the `Policy`. Returns an empty hash 38 | # if the `Policy` is not valid. 39 | def to_hash 40 | return {} of String => String unless valid? 41 | 42 | { 43 | "expiration" => @expiration.not_nil!.to_s("%Y-%m-%dT%H:%M:%S.000Z"), 44 | "conditions" => @fields.map(&.serialize), 45 | } 46 | end 47 | 48 | # Returns the `Policy` has Base64 encoded JSON. 49 | def to_s(io : IO) 50 | io << Base64.strict_encode(to_hash.to_json) 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /src/aws/s3/file_uploader.cr: -------------------------------------------------------------------------------- 1 | require "./content_type" 2 | 3 | module Aws::S3 4 | # Uploads a file to S3. If the file is 5MB it is uploaded in a single request. 5 | # If the file is greater than 5MB it is uploaded in parts. 6 | class FileUploader 7 | # :nodoc: 8 | UPLOAD_THRESHOLD = 5_000_000 # 5mb 9 | 10 | # Configurable options passed to a FileUploader instance 11 | struct Options 12 | # If true the uploader will automatically add a content type header 13 | getter with_content_types 14 | 15 | def initialize(@with_content_types : Bool) 16 | end 17 | end 18 | 19 | def initialize(@client : Client, @options : Options = Options.new(with_content_types: true)) 20 | end 21 | 22 | # Upload a file to a bucket. Returns true if successful, otherwise an 23 | # `Http::ServerError` is thrown. 24 | # 25 | # ``` 26 | # uploader = FileUpload.new(client) 27 | # uploader.upload("bucket1", "obj", IO::Memory.new("DATA!")) 28 | # ``` 29 | def upload(bucket : String, object : String, io : IO, headers : Hash(String, String) = Hash(String, String).new) 30 | headers = @options.with_content_types ? headers.merge(content_type_header(io)) : headers 31 | 32 | if io.size < UPLOAD_THRESHOLD 33 | @client.put_object(bucket, object, io, headers) 34 | else 35 | uploader = MultipartFileUploader.new(@client) 36 | uploader.upload(bucket, object, io, headers) 37 | end 38 | true 39 | end 40 | 41 | def content_type_header(io : IO) 42 | {"Content-Type" => Aws::S3::ContentType.get(io)} 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/field_collection.cr: -------------------------------------------------------------------------------- 1 | require "./post_field" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | # Holds a collection of `PostField`s. 7 | # 8 | # ``` 9 | # fields = FieldCollection.new 10 | # fields.to_a # => [] of PostField 11 | # field["test"] = "test" 12 | # field.push(PostField.new("Hi", "Hi")) 13 | # ``` 14 | class FieldCollection 15 | include Enumerable(PostField) 16 | 17 | def initialize 18 | @fields = [] of PostField 19 | end 20 | 21 | # Adds a new `PostField` to the collection. 22 | def push(field : PostField) 23 | return false if @fields.includes?(field) 24 | @fields << field 25 | true 26 | end 27 | 28 | # Iterate over the collection's fields. 29 | def each(&block) 30 | @fields.each do |field| 31 | yield field 32 | end 33 | end 34 | 35 | # Access a key, by name, from the field collection. Returns the key if 36 | # found, otherwise returns nil. 37 | def [](key) 38 | self.find { |field| clean_key(field.key) == clean_key(key) }.try(&.value) 39 | end 40 | 41 | # Convert the collection to a hash in the form of key => value. 42 | def to_hash 43 | self.reduce({} of String => String) do |hash, field| 44 | hash[field.key] = field.value 45 | hash 46 | end 47 | end 48 | 49 | # :nodoc: 50 | private def clean_key(key) 51 | key.gsub("-", "_").downcase 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /src/aws/s3/responses/list_objects_v2.cr: -------------------------------------------------------------------------------- 1 | require "xml" 2 | require "uri" 3 | 4 | module Aws::S3::Response 5 | class ListObjectsV2 6 | # Create a `ListObjectsV2` response from an 7 | # `HTTP::Client::Response` object 8 | def self.from_response(response) 9 | xml = Utils::XML.new(response.body) 10 | 11 | name = xml.string("//ListBucketResult/Name") 12 | prefix = xml.string("//ListBucketResult/Prefix") 13 | key_count = xml.string("//ListBucketResult/KeyCount") 14 | max_keys = xml.string("//ListBucketResult/MaxKeys") 15 | truncated = xml.string("//ListBucketResult/IsTruncated") 16 | token = xml.string("//ListBucketResult/NextContinuationToken") 17 | 18 | objects = [] of Object 19 | xml.array("ListBucketResult/Contents") do |object| 20 | key = object.string("Key") 21 | size = object.string("Size").to_i 22 | etag = object.string("ETag") 23 | 24 | objects << Object.new(key, size, etag) 25 | end 26 | 27 | new(name, prefix, key_count.to_i, max_keys.to_i, truncated == "true", token, objects) 28 | end 29 | 30 | # The list of obects 31 | getter contents 32 | 33 | def initialize(@name : String, @prefix : String, @key_count : Int32, 34 | @max_keys : Int32, @truncated : Bool, @continuation_token : String, @contents : Array(Object)) 35 | end 36 | 37 | # The continuation token for the subsequent response, if any 38 | def next_token 39 | @continuation_token 40 | end 41 | 42 | # Returns true if the response is truncated, false otherwise 43 | def truncated? 44 | @truncated 45 | end 46 | 47 | def_equals @name, @prefix, @key_count, @max_keys, @truncated, 48 | @continuation_token, @contents 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /src/aws/s3/responses/batch_delete_output.cr: -------------------------------------------------------------------------------- 1 | module Aws::S3::Response 2 | class BatchDeleteOutput 3 | class DeletedObject 4 | # The key of the deleted object 5 | getter key 6 | 7 | # The failure code 8 | getter code 9 | 10 | # Human friendly failure message 11 | getter message 12 | 13 | def initialize(@key : String, @code : String, @message : String) 14 | end 15 | 16 | # Returns true of object was deleted, false otherwise 17 | def deleted? 18 | @code.empty? 19 | end 20 | 21 | def_equals @key, @code, @message 22 | end 23 | 24 | # Create a `CompleteMultipartUpload` response from an 25 | # `HTTP::Client::Response` object 26 | def self.from_response(response) 27 | xml = Utils::XML.new(response.body) 28 | 29 | xml.string("//DeleteResult/Location") 30 | 31 | objects = [] of DeletedObject 32 | xml.array("DeleteResult/Deleted") do |object| 33 | key = object.string("Key") 34 | code = object.string("Code") 35 | msg = object.string("Message") 36 | 37 | objects << DeletedObject.new(key, code, msg) 38 | end 39 | 40 | new(objects) 41 | end 42 | 43 | @objects : Array(DeletedObject) 44 | 45 | def initialize(objects) 46 | @objects = objects 47 | end 48 | 49 | # Returns true if all objects were deleted, false otherwise. 50 | def success? 51 | !@objects.map(&.deleted?).includes?(false) 52 | end 53 | 54 | # Returns an array of objects that were successfully deleted 55 | def deleted_objects 56 | @objects.select(&.deleted?) 57 | end 58 | 59 | # Returns an array of objects that failed to be deleted 60 | def failed_objects 61 | @objects.reject(&.deleted?) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/aws/sqs/client.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | module Sqs 3 | # An Sqs client for interacting with Sqs. 4 | # 5 | # Creating an Sqs Client 6 | # 7 | # ``` 8 | # client = Client.new("region", "key", "secret") 9 | # ``` 10 | # 11 | # Client with custom endpoint 12 | # ``` 13 | # client = Client.new("region", "key", "secret", endpoint: "http://test.com") 14 | # ``` 15 | # 16 | # Client with custom signer algorithm 17 | # ``` 18 | # client = Client.new("region", "key", "secret", signer: :v2) 19 | # ``` 20 | class Client 21 | @signer : Awscr::Signer::Signers::Interface 22 | 23 | def initialize(@region : String, @aws_access_key : String, @aws_secret_key : String, @endpoint : String? = nil, signer : Symbol = :v4) 24 | @signer = Utils::SignerFactory.get( 25 | service_name: Aws::Sqs::SERVICE_NAME, 26 | version: signer, 27 | region: @region, 28 | aws_access_key: @aws_access_key, 29 | aws_secret_key: @aws_secret_key 30 | ) 31 | end 32 | 33 | def create_queue(name : String) 34 | resp = http.get("/?Action=CreateQueue&QueueName=#{name}&Version=2012-11-05") 35 | 36 | resp.status_code == 200 37 | end 38 | 39 | def send_message(queue : String, message : String) 40 | params = "Action=SendMessage&MessageBody=#{message}&Version=2012-11-05" 41 | resp = http.post("/#{queue}", headers: {"Content-Type" => "application/x-www-form-urlencoded"}, body: params) 42 | 43 | resp.status_code == 200 44 | end 45 | 46 | # :nodoc: 47 | private def http 48 | Utils::Http.new(signer: @signer, service_name: Aws::Sqs::SERVICE_NAME, region: @region, custom_endpoint: @endpoint) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/url_options.cr: -------------------------------------------------------------------------------- 1 | module Aws 2 | module S3 3 | module Presigned 4 | class Url 5 | # Options for generating a `Presigned::Url` 6 | struct Options 7 | # The bucket for the presigned url 8 | getter bucket 9 | 10 | # The object key, it must start with '/' 11 | getter object 12 | 13 | # When the link expires, defaults to 1 day 14 | getter expires 15 | 16 | # Additional presigned options 17 | getter additional_options 18 | 19 | # Aws access key 20 | getter aws_access_key 21 | 22 | # Aws secret key 23 | getter aws_secret_key 24 | 25 | # The Aws region 26 | getter region 27 | 28 | # Optionally set the host name to use. The default is s3.amazonaws.com 29 | getter host_name 30 | 31 | @expires : Int32 32 | @additional_options : Hash(String, String) 33 | @bucket : String 34 | @object : String 35 | @region : String 36 | @aws_access_key : String 37 | @aws_secret_key : String 38 | @host_name : String? 39 | 40 | def initialize(@aws_access_key, @aws_secret_key, @region, 41 | @object, @bucket, @expires = 86_400, @host_name = nil, 42 | @additional_options = {} of String => String, @signer = :v4) 43 | end 44 | 45 | def signer_version 46 | @signer 47 | end 48 | 49 | def signer 50 | Utils::SignerFactory.get( 51 | service_name: Aws::S3::SERVICE_NAME, 52 | version: @signer, 53 | region: @region, 54 | aws_access_key: @aws_access_key, 55 | aws_secret_key: @aws_secret_key 56 | ) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/s3/presigned/post_policy_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | describe Policy do 7 | describe "eq" do 8 | it "adds a field" do 9 | policy = Policy.new 10 | policy.expiration(Time.now) 11 | policy.condition("test", "test") 12 | 13 | policy.fields.size.should eq 1 14 | end 15 | 16 | it "returns self" do 17 | policy = Policy.new 18 | policy.expiration(Time.now) 19 | 20 | policy.condition("test", "test").should eq policy 21 | end 22 | end 23 | 24 | describe "valid?" do 25 | it "returns true if expiration is set" do 26 | policy = Policy.new 27 | policy.expiration(Time.now) 28 | 29 | policy.valid?.should be_true 30 | end 31 | 32 | it "returns false if expiration is not set" do 33 | policy = Policy.new 34 | 35 | policy.valid?.should be_false 36 | end 37 | end 38 | 39 | describe "to_s" do 40 | it "returns policy as base64 encoded json" do 41 | policy = Policy.new 42 | policy.expiration(Time.unix(1_483_859_302)) 43 | policy.condition("test", "test") 44 | 45 | policy.to_s.should eq("eyJleHBpcmF0aW9uIjoiMjAxNy0wMS0wOFQwNzowODoyMi4wMDBaIiwiY29uZGl0aW9ucyI6W3sidGVzdCI6InRlc3QifV19") 46 | end 47 | end 48 | 49 | describe "to_hash" do 50 | it "returns empty hash if policy is not valid" do 51 | policy = Policy.new 52 | policy.to_hash.should eq({} of String => String) 53 | end 54 | 55 | it "can be a hash" do 56 | policy = Policy.new 57 | policy.expiration(Time.unix(1_483_859_302)) 58 | policy.condition("test", "test") 59 | 60 | policy.to_hash.should eq({ 61 | "expiration" => "2017-01-08T07:08:22.000Z", 62 | "conditions" => [ 63 | {"test" => "test"}, 64 | ], 65 | }) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/s3/presigned/field_collection_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | class TestField < PostField 7 | def serialize 8 | end 9 | end 10 | 11 | describe FieldCollection do 12 | it "is enumerable" do 13 | fields = FieldCollection.new 14 | fields.should be_a(Enumerable(PostField)) 15 | end 16 | 17 | it "can have fields added to it" do 18 | field = TestField.new("k", "v") 19 | 20 | fields = FieldCollection.new 21 | fields.push(field) 22 | 23 | fields.to_a.should eq([field]) 24 | end 25 | 26 | it "is empty by default" do 27 | fields = FieldCollection.new 28 | 29 | fields.to_a.should eq([] of PostField) 30 | end 31 | 32 | it "does not add dupes" do 33 | field = TestField.new("k", "v") 34 | 35 | fields = FieldCollection.new 36 | 5.times { fields.push(field) } 37 | 38 | fields.to_a.should eq([field]) 39 | end 40 | 41 | describe "to_hash" do 42 | it "converts to named tuple" do 43 | fields = FieldCollection.new 44 | fields.push(TestField.new("k", "v")) 45 | 46 | fields.to_hash.should eq({"k" => "v"}) 47 | end 48 | end 49 | 50 | describe "[]" do 51 | it "returns nil if no key found" do 52 | fields = FieldCollection.new 53 | fields["k"].should eq nil 54 | end 55 | 56 | it "can return a key value" do 57 | fields = FieldCollection.new 58 | fields.push(TestField.new("k", "v")) 59 | 60 | fields["k"].should eq "v" 61 | end 62 | 63 | it "does not care about case" do 64 | fields = FieldCollection.new 65 | fields.push(TestField.new("k", "v")) 66 | fields["K"].should eq "v" 67 | 68 | fields = FieldCollection.new 69 | fields.push(TestField.new("K", "v")) 70 | fields["k"].should eq "v" 71 | end 72 | 73 | it "can look up hypenated keys via an underscore" do 74 | fields = FieldCollection.new 75 | fields.push(TestField.new("k-v", "v")) 76 | fields["k_v"].should eq "v" 77 | end 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/form.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "http/client" 3 | require "uuid" 4 | require "./post" 5 | 6 | module Aws 7 | module S3 8 | module Presigned 9 | class Form 10 | @client : HTTP::Client 11 | @boundary : String 12 | 13 | # Create a new `Form` 14 | # 15 | # Building a `Form` 16 | # 17 | # ``` 18 | # Aws::S3::Presigned::Form.build("us-east-1", "aws key", "aws secret") do |form| 19 | # form.expiration(Time.utc_now.to_unix + 1000) 20 | # form.condition("bucket", "my bucket") 21 | # form.condition("acl", "public-read") 22 | # form.condition("key", "helloworld.png") 23 | # form.condition("Content-Type", "image/png") 24 | # form.condition("success_action_status", "201") 25 | # end 26 | # ``` 27 | def self.build(region, aws_access_key, aws_secret_key, signer = :v4, &block) 28 | post = Post.new(region, aws_access_key, aws_secret_key, signer) 29 | post.build do |p| 30 | yield p 31 | end 32 | new(post, HTTP::Client.new(URI.parse(post.url))) 33 | end 34 | 35 | # Create a form with a Post object and an IO. 36 | def initialize(@post : Post, client : HTTP::Client) 37 | @boundary = UUID.random.to_s 38 | @client = client 39 | end 40 | 41 | # Submit the `Form`. 42 | def submit(io : IO) 43 | @client.post("/", headers, body(io).to_s) 44 | end 45 | 46 | # Represent this `Presigned::Form` as raw HTML. 47 | def to_html 48 | HtmlPrinter.new(self) 49 | end 50 | 51 | # The url of the form. 52 | def url 53 | @post.url 54 | end 55 | 56 | # The fields of the form. 57 | def fields 58 | @post.fields 59 | end 60 | 61 | # :nodoc: 62 | private def headers 63 | HTTP::Headers{"Content-Type" => %(multipart/form-data; boundary="#{@boundary}")} 64 | end 65 | 66 | # :nodoc: 67 | private def body(io : IO) 68 | body_io = IO::Memory.new 69 | HTTP::FormData.build(body_io, @boundary) do |form| 70 | @post.fields.each do |field| 71 | form.field(field.key, field.value) 72 | end 73 | form.file("file", io) 74 | end 75 | body_io 76 | end 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/s3/presigned/html_printer_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | describe HtmlPrinter do 7 | Spec.before_each do 8 | Timecop.freeze(Time.unix(1)) 9 | end 10 | 11 | Spec.after_each do 12 | Timecop.reset 13 | end 14 | 15 | it "generates the same html each call" do 16 | time = Time.unix(1) 17 | post = Post.new( 18 | region: "us-east-1", 19 | aws_access_key: "test", 20 | aws_secret_key: "test" 21 | ) 22 | 23 | post.build do |b| 24 | b.expiration(time) 25 | b.condition("bucket", "test") 26 | end 27 | form = Form.new(post, HTTP::Client.new("")) 28 | 29 | printer = HtmlPrinter.new(form) 30 | 31 | printer.print.should eq(printer.print) 32 | end 33 | 34 | it "prints html" do 35 | time = Time.unix(1) 36 | 37 | post = Post.new( 38 | region: "region", 39 | aws_access_key: "test", 40 | aws_secret_key: "test" 41 | ) 42 | 43 | post.build do |b| 44 | b.expiration(time) 45 | b.condition("bucket", "test") 46 | end 47 | form = Form.new(post, HTTP::Client.new("")) 48 | 49 | printer = HtmlPrinter.new(form) 50 | 51 | html = <<-HTML 52 |
53 |
54 |
55 |
56 |
57 |
58 | 59 | 60 |
61 | 62 |
63 | HTML 64 | 65 | printer.print.gsub("\n", "").gsub(" ", "").should eq(html.gsub("\n", "").gsub(" ", "")) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/url.cr: -------------------------------------------------------------------------------- 1 | require "./url_options" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | # A Presigned::URL, useful to share a link or create a link for a direct 7 | # PUT. 8 | class Url 9 | @aws_access_key : String 10 | @aws_secret_key : String 11 | @region : String 12 | 13 | def initialize(@options : Options) 14 | @aws_access_key = @options.aws_access_key 15 | @aws_secret_key = @options.aws_secret_key 16 | @region = @options.region 17 | end 18 | 19 | # Create a Presigned::Url link. Supports GET and PUT. 20 | def for(method : Symbol) 21 | raise "unsupported method #{method}" unless allowed_methods.includes?(method) 22 | 23 | request = build_request(method.to_s.upcase) 24 | 25 | @options.additional_options.each do |k, v| 26 | request.query_params.add(k, v) 27 | end 28 | 29 | presign_request(request) 30 | 31 | String.build do |str| 32 | str << "https://" 33 | str << request.host 34 | str << request.resource 35 | end 36 | end 37 | 38 | # :nodoc: 39 | private def presign_request(request) 40 | @options.signer.presign(request) 41 | end 42 | 43 | # :nodoc: 44 | private def build_request(method) 45 | headers = HTTP::Headers{"Host" => host} 46 | 47 | body = @options.signer_version == :v4 ? "UNSIGNED-PAYLOAD" : nil 48 | 49 | request = HTTP::Request.new( 50 | method, 51 | "/#{@options.bucket}#{@options.object}", 52 | headers, 53 | body 54 | ) 55 | 56 | if @options.signer_version == :v4 57 | request.query_params.add("X-Amz-Expires", @options.expires.to_s) 58 | else 59 | request.query_params.add("Expires", (Time.utc_now.to_unix + Time.unix(@options.expires).to_unix).to_s) 60 | end 61 | 62 | request 63 | end 64 | 65 | # :nodoc: 66 | private def host 67 | if host_name = @options.host_name 68 | host_name 69 | else 70 | return default_host if @region == standard_us_region 71 | "s3-#{@region}.amazonaws.com" 72 | end 73 | end 74 | 75 | # :nodoc: 76 | private def standard_us_region 77 | "us-east-1" 78 | end 79 | 80 | # :nodoc: 81 | private def default_host 82 | "s3.amazonaws.com" 83 | end 84 | 85 | # :nodoc: 86 | private def allowed_methods 87 | [:get, :put] 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/utils/xml_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws 4 | module Utils 5 | describe XML do 6 | it "handle flattened" do 7 | resp = <<-RESP 8 | 9 | 10 | bucket 11 | 12 | 205 13 | 1000 14 | false 15 | 16 | my-image.jpg 17 | 2009-10-12T17:50:30.000Z 18 | "fba9dede5f27731c9771645a39863328" 19 | 434234 20 | STANDARD 21 | 22 | 23 | key2 24 | 2009-10-12T17:50:30.000Z 25 | "fba9dede5f27731c9771645a39863329" 26 | 1337 27 | STANDARD 28 | 29 | 30 | RESP 31 | 32 | xml = XML.new(resp) 33 | 34 | keys = [] of String 35 | xml.array("ListBucketResult/Contents") do |node| 36 | keys << node.string("Key") 37 | end 38 | 39 | keys.should eq(["my-image.jpg", "key2"]) 40 | end 41 | 42 | it "is ok if not namespaced" do 43 | resp = <<-RESP 44 | 45 | 46 | 47 | 48 | samples 49 | 2006-02-03T16:41:58.000Z 50 | 51 | 52 | 53 | RESP 54 | 55 | xml = XML.new(resp) 56 | xml.array("ListAllMyBucketsResult/Buckets/Bucket") do |node| 57 | node.string("Name").should eq("samples") 58 | node.should be_a(XML::NamespacedNode) 59 | end 60 | end 61 | 62 | it "handles namespacing" do 63 | resp = <<-RESP 64 | 65 | 66 | 67 | 68 | samples 69 | 2006-02-03T16:41:58.000Z 70 | 71 | 72 | 73 | RESP 74 | 75 | xml = XML.new(resp) 76 | xml.array("ListAllMyBucketsResult/Buckets/Bucket") do |node| 77 | node.should be_a(XML::NamespacedNode) 78 | node.string("Name").should eq("samples") 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /src/aws/s3/presigned/post.cr: -------------------------------------------------------------------------------- 1 | require "./post_policy" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | # Represents the URL and fields required to send a HTTP form POST to S3 7 | # for object uploading. 8 | class Post 9 | def initialize(@region : String, @aws_access_key : String, 10 | @aws_secret_key : String, @signer : Symbol = :v4) 11 | @policy = Policy.new 12 | end 13 | 14 | # Build a post object by adding fields 15 | def build(&block) 16 | yield @policy 17 | 18 | add_fields_before_sign 19 | 20 | signature = signer.sign(@policy.to_s) 21 | 22 | add_fields_after_sign(signature) 23 | 24 | self 25 | end 26 | 27 | # Returns if the post is valid, false otherwise 28 | def valid? 29 | !!(bucket && @policy.valid?) 30 | end 31 | 32 | # Return the url to post to 33 | def url 34 | raise Exception.new("Invalid URL, no bucket field") unless bucket 35 | "http://#{bucket}.s3.amazonaws.com" 36 | end 37 | 38 | # Returns the fields, without signature fields 39 | def fields 40 | @policy.fields 41 | end 42 | 43 | # :nodoc: 44 | private def credential_scope(time) 45 | [@aws_access_key, time.to_s("%Y%m%d"), @region, SERVICE_NAME, "aws4_request"].join("/") 46 | end 47 | 48 | # :nodoc: 49 | private def bucket 50 | if bucket = fields.find { |field| field.key == "bucket" } 51 | bucket.value 52 | end 53 | end 54 | 55 | private def add_fields_after_sign(signature) 56 | @policy.condition("policy", @policy.to_s) 57 | 58 | case @signer 59 | when :v4 60 | @policy.condition("x-amz-signature", signature.to_s) 61 | when :v2 62 | @policy.condition("AWSAccessKeyId", @aws_access_key) 63 | @policy.condition("Signature", signature.to_s) 64 | end 65 | end 66 | 67 | private def add_fields_before_sign 68 | case @signer 69 | when :v4 70 | time = Time.utc_now 71 | @policy.condition("x-amz-credential", credential_scope(time)) 72 | @policy.condition("x-amz-algorithm", Awscr::Signer::ALGORITHM) 73 | @policy.condition("x-amz-date", time.to_s("%Y%m%dT%H%M%SZ")) 74 | when :v2 75 | # do nothing 76 | end 77 | end 78 | 79 | private def signer 80 | Utils::SignerFactory.get( 81 | service_name: Aws::S3::SERVICE_NAME, 82 | version: @signer, 83 | region: @region, 84 | aws_access_key: @aws_access_key, 85 | aws_secret_key: @aws_secret_key 86 | ) 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /src/aws/s3/multipart_file_uploader.cr: -------------------------------------------------------------------------------- 1 | module Aws::S3 2 | # :nodoc: 3 | private class Part 4 | getter offset 5 | getter size 6 | getter number 7 | 8 | def initialize(@offset : Int32, @size : Int32, @number : Int32) 9 | end 10 | end 11 | 12 | # :nodoc: 13 | private class MultipartFileUploader 14 | getter client 15 | 16 | @upload_id : String? 17 | @bucket : String? 18 | @object : String? 19 | @headers : Hash(String, String)? 20 | 21 | def initialize(@client : Client) 22 | @pending = [] of Part 23 | @parts = [] of Response::UploadPartOutput 24 | @channel = Channel(Nil).new 25 | end 26 | 27 | # Uploads an *object* to a *bucket*, in multiple parts 28 | def upload(bucket : String, object : String, io : IO, headers : Hash(String, String) = Hash(String, String).new) 29 | @bucket = bucket 30 | @object = object 31 | @headers = headers 32 | @upload_id = start_upload 33 | 34 | build_pending_parts(io) 35 | upload_pending(io) 36 | complete_upload 37 | end 38 | 39 | private def build_pending_parts(io) 40 | offset = 0 41 | size = io.size 42 | default_part_size = compute_default_part_size(io.size) 43 | number = 1 44 | 45 | loop do 46 | @pending << Part.new( 47 | offset: offset, 48 | size: part_size(size, default_part_size, offset).to_i32, 49 | number: number 50 | ) 51 | 52 | offset += default_part_size 53 | 54 | number += 1 55 | 56 | break if offset >= size 57 | end 58 | end 59 | 60 | private def compute_default_part_size(source_size) 61 | [(source_size / 10_000).ceil, 5 * 1024 * 1024].max 62 | end 63 | 64 | private def part_size(total_size, part_size, offset) 65 | if offset + part_size > total_size 66 | total_size - offset 67 | else 68 | part_size 69 | end 70 | end 71 | 72 | private def upload_pending(io) 73 | @pending.each do |part| 74 | bytes = Bytes.new(part.size) 75 | 76 | io.skip(part.offset) 77 | io.read(bytes) 78 | io.rewind 79 | 80 | spawn upload_part(bytes, part) 81 | end 82 | 83 | @pending.size.times { @channel.receive } 84 | end 85 | 86 | private def upload_part(bytes, part) 87 | @parts << client.upload_part( 88 | bucket, 89 | object, 90 | upload_id, 91 | part.number, 92 | IO::Memory.new(bytes) 93 | ) 94 | ensure 95 | @channel.send(nil) 96 | end 97 | 98 | private def complete_upload 99 | client.complete_multipart_upload( 100 | bucket, 101 | object, 102 | upload_id, 103 | @parts.sort_by(&.part_number) 104 | ) 105 | end 106 | 107 | private def start_upload 108 | resp = client.start_multipart_upload(bucket, object, headers) 109 | resp.upload_id 110 | end 111 | 112 | private def upload_id 113 | @upload_id.not_nil! 114 | end 115 | 116 | private def bucket 117 | @bucket.not_nil! 118 | end 119 | 120 | private def object 121 | @object.not_nil! 122 | end 123 | 124 | private def headers 125 | @headers.not_nil! 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /src/aws/s3/content_type.cr: -------------------------------------------------------------------------------- 1 | module Aws::S3 2 | # Determines the Content-Type of IO 3 | class ContentType 4 | # The default content type if one can not be determined from the filename 5 | DEFAULT = "binary/octet-stream" 6 | 7 | # :nodoc: 8 | TYPES = { 9 | ".aac" => "audio/aac", 10 | ".abw" => "application/x-abiword", 11 | ".arc" => "application/octet-stream", 12 | ".avi" => "video/x-msvideo", 13 | ".azw" => "application/vnd.amazon.ebook", 14 | ".bin" => "application/octet-stream", 15 | ".bz" => "application/x-bzip", 16 | ".bz2" => "application/x-bzip2", 17 | ".csh" => "application/x-csh", 18 | ".css" => "text/css", 19 | ".csv" => "text/csv", 20 | ".doc" => "application/msword", 21 | ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 22 | ".eot" => "application/vnd.ms-fontobject", 23 | ".epub" => "application/epub+zip", 24 | ".gif" => "image/gif", 25 | ".htm" => "text/html", 26 | ".html" => "text/html", 27 | ".ico" => "image/x-icon", 28 | ".ics" => "text/calendar", 29 | ".jar" => "application/java-archive", 30 | ".jpeg" => "image/jpeg", 31 | ".jpg" => "image/jpeg", 32 | ".js" => "application/javascript", 33 | ".json" => "application/json", 34 | ".mid" => "audio/midi", 35 | ".midi" => "audio/midi", 36 | ".mpeg" => "video/mpeg", 37 | ".mpkg" => "application/vnd.apple.installer+xml", 38 | ".odp" => "application/vnd.oasis.opendocument.presentation", 39 | ".ods" => "application/vnd.oasis.opendocument.spreadsheet", 40 | ".odt" => "application/vnd.oasis.opendocument.text", 41 | ".oga" => "audio/ogg", 42 | ".ogv" => "video/ogg", 43 | ".ogx" => "application/ogg", 44 | ".otf" => "font/otf", 45 | ".png" => "image/png", 46 | ".pdf" => "application/pdf", 47 | ".ppt" => "application/vnd.ms-powerpoint", 48 | ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", 49 | ".rar" => "application/x-rar-compressed", 50 | ".rtf" => "application/rtf", 51 | ".sh" => "application/x-sh", 52 | ".svg" => "image/svg+xml", 53 | ".swf" => "application/x-shockwave-flash", 54 | ".tar" => "application/x-tar", 55 | ".tif" => "image/tiff", 56 | ".tiff" => "image/tiff", 57 | ".ts" => "application/typescript", 58 | ".ttf" => "font/ttf", 59 | ".vsd" => "application/vnd.visio", 60 | ".wav" => "audio/x-wav", 61 | ".weba" => "audio/webm", 62 | ".webm" => "video/webm", 63 | ".webp" => "image/webp", 64 | ".woff" => "font/woff", 65 | ".woff2" => "font/woff2", 66 | ".xhtml" => "application/xhtml+xml", 67 | ".xls" => "application/vnd.ms-excel", 68 | ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 69 | ".xml" => "application/xml", 70 | ".xul" => "application/vnd.mozilla.xul+xml", 71 | ".zip" => "application/zip", 72 | ".3gp" => "video/3gpp", 73 | ".3g2" => "video/3gpp2", 74 | ".7z" => "application/x-7z-compressed", 75 | } 76 | 77 | # Gets a content type based on the file extesion, if there is no file 78 | # extension it uses the default content type 79 | def self.get(io : IO) : String 80 | case io 81 | when .responds_to?(:path) 82 | extension = File.extname(io.path) 83 | TYPES.fetch(extension, DEFAULT) 84 | else 85 | DEFAULT 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/s3/presigned/url_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | describe Url do 7 | it "allows signer versions" do 8 | Url::Options.new( 9 | region: "ap-northeast-1", 10 | aws_access_key: "AKIAIOSFODNN7EXAMPLE", 11 | aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 12 | bucket: "examplebucket", 13 | object: "/test.txt", 14 | signer: :v2 15 | ) 16 | end 17 | 18 | it "uses region specific hosts" do 19 | options = Url::Options.new( 20 | region: "ap-northeast-1", 21 | aws_access_key: "AKIAIOSFODNN7EXAMPLE", 22 | aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 23 | bucket: "examplebucket", 24 | object: "/test.txt" 25 | ) 26 | url = Url.new(options) 27 | 28 | url.for(:get).should match(/https:\/\/s3-#{options.region}.amazonaws.com/) 29 | end 30 | 31 | it "allows host override" do 32 | options = Url::Options.new( 33 | region: "us-east-1", 34 | aws_access_key: "AKIAIOSFODNN7EXAMPLE", 35 | aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 36 | bucket: "examplebucket", 37 | object: "/test.txt", 38 | host_name: "examplebucket.s3.amazonaws.com" 39 | ) 40 | url = Url.new(options) 41 | 42 | url.for(:get).should match(/https:\/\/examplebucket.s3.amazonaws.com/) 43 | end 44 | 45 | it "raises on unsupported method" do 46 | options = Url::Options.new( 47 | region: "us-east-1", 48 | aws_access_key: "AKIAIOSFODNN7EXAMPLE", 49 | aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 50 | bucket: "examplebucket", 51 | object: "/test.txt" 52 | ) 53 | url = Url.new(options) 54 | 55 | expect_raises(Exception) do 56 | url.for(:test) 57 | end 58 | end 59 | 60 | describe "get" do 61 | it "generates correct url for v2" do 62 | time = Time.unix(1) 63 | Timecop.freeze(time) 64 | options = Url::Options.new( 65 | region: "us-east-1", 66 | aws_access_key: "AKIAIOSFODNN7EXAMPLE", 67 | aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 68 | bucket: "examplebucket", 69 | object: "/test.txt", 70 | signer: :v2 71 | ) 72 | url = Url.new(options) 73 | 74 | url.for(:get) 75 | .should eq("https://s3.amazonaws.com/examplebucket/test.txt?Expires=86401&AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Signature=KP7uBvqYauy%2Fzj1Rb9LgL7e87VY%3D") 76 | end 77 | 78 | it "generates a correct url for v4" do 79 | Timecop.freeze(Time.new(2013, 5, 24)) do 80 | options = Url::Options.new( 81 | region: "us-east-1", 82 | aws_access_key: "AKIAIOSFODNN7EXAMPLE", 83 | aws_secret_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 84 | bucket: "examplebucket", 85 | object: "/test.txt" 86 | ) 87 | url = Url.new(options) 88 | 89 | url.for(:get) 90 | .should eq("https://s3.amazonaws.com/examplebucket/test.txt?X-Amz-Expires=86400&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-SignedHeaders=host&X-Amz-Signature=733255ef022bec3f2a8701cd61d4b371f3f28c9f193a1f02279211d48d5193d7") 91 | end 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /src/aws/utils/http.cr: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "xml" 3 | 4 | module Aws 5 | module Utils 6 | class Http 7 | # Exception raised when Sqs gives us a non 200 http status code. The error 8 | # will have a specific message from Sqs. 9 | class ServerError < Exception 10 | # Creates a `ServerError` from an `HTTP::Client::Response` 11 | def self.from_response(response) 12 | xml = XML.new(response.body) 13 | 14 | code = xml.string("//Error/Code") 15 | message = xml.string("//Error/Message") 16 | 17 | new("#{code}: #{message}") 18 | end 19 | end 20 | 21 | def initialize(@signer : Awscr::Signer::Signers::Interface, 22 | @service_name : String, 23 | @region : String, 24 | @custom_endpoint : String? = nil) 25 | @http = HTTP::Client.new(endpoint) 26 | 27 | @http.before_request do |request| 28 | @signer.sign(request) 29 | end 30 | end 31 | 32 | # Issue a DELETE request to the *path* with optional *headers* 33 | # 34 | # ``` 35 | # http = Http.new(signer) 36 | # http.delete("/") 37 | # ``` 38 | def delete(path, headers : Hash(String, String) = Hash(String, String).new) 39 | headers = HTTP::Headers.new.merge!(headers) 40 | resp = @http.delete(path, headers: headers) 41 | handle_response!(resp) 42 | end 43 | 44 | # Issue a POST request to the *path* with optional *headers*, and *body* 45 | # 46 | # ``` 47 | # http = Http.new(signer) 48 | # http.post("/", body: IO::Memory.new("test")) 49 | # ``` 50 | def post(path, body = nil, headers : Hash(String, String) = Hash(String, String).new) 51 | headers = HTTP::Headers.new.merge!(headers) 52 | resp = @http.post(path, headers: headers, body: body) 53 | handle_response!(resp) 54 | end 55 | 56 | # Issue a PUT request to the *path* with optional *headers* and *body* 57 | # 58 | # ``` 59 | # http = Http.new(signer) 60 | # http.put("/", body: IO::Memory.new("test")) 61 | # ``` 62 | def put(path : String, body : IO | String, headers : Hash(String, String) = Hash(String, String).new) 63 | headers = HTTP::Headers{"Content-Length" => body.size.to_s}.merge!(headers) 64 | resp = @http.put(path, headers: headers, body: body) 65 | handle_response!(resp) 66 | end 67 | 68 | # Issue a HEAD request to the *path* 69 | # 70 | # ``` 71 | # http = Http.new(signer) 72 | # http.head("/") 73 | # ``` 74 | def head(path) 75 | resp = @http.head(path) 76 | handle_response!(resp) 77 | end 78 | 79 | # Issue a GET request to the *path* 80 | # 81 | # ``` 82 | # http = Http.new(signer) 83 | # http.get("/") 84 | # ``` 85 | def get(path) 86 | resp = @http.get(path) 87 | handle_response!(resp) 88 | end 89 | 90 | # :nodoc: 91 | private def handle_response!(response) 92 | return response if (200..299).includes?(response.status_code) 93 | 94 | if !response.body.empty? 95 | raise ServerError.from_response(response) 96 | else 97 | raise ServerError.new("server error: #{response.status_code}") 98 | end 99 | end 100 | 101 | # :nodoc: 102 | private def endpoint : URI 103 | return URI.parse(@custom_endpoint.to_s) if @custom_endpoint 104 | URI.parse("http://#{@service_name}.#{@region}.amazonaws.com") 105 | end 106 | 107 | # :nodoc: 108 | private def default_endpoint : URI 109 | URI.parse("http://#{@service_name}.amazonaws.com") 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/s3/presigned/form_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | describe Form do 7 | describe ".build" do 8 | it "builds a form" do 9 | form = Form.build( 10 | region: "us-east-1", 11 | aws_access_key: "test", 12 | aws_secret_key: "test") do |f| 13 | f.condition("bucket", "2") 14 | end 15 | 16 | form.should be_a(Form) 17 | form.fields.size.should eq 6 # bucket, and all the sig and policy stuff 18 | end 19 | end 20 | 21 | describe "url" do 22 | it "returns form url" do 23 | form = Form.build( 24 | region: "us-east-1", 25 | aws_access_key: "test", 26 | aws_secret_key: "test") do |f| 27 | f.condition("bucket", "hi") 28 | end 29 | 30 | form.url.should eq("http://hi.s3.amazonaws.com") 31 | end 32 | end 33 | 34 | describe "fields" do 35 | it "is a field collection" do 36 | post = Post.new( 37 | region: "us-east-1", 38 | aws_access_key: "test", 39 | aws_secret_key: "test" 40 | ) 41 | form = Form.new(post, HTTP::Client.new("host")) 42 | 43 | form.fields.should be_a(FieldCollection) 44 | end 45 | end 46 | 47 | describe "to_html" do 48 | it "returns an html printer" do 49 | post = Post.new( 50 | region: "us-east-1", 51 | aws_access_key: "test", 52 | aws_secret_key: "test" 53 | ) 54 | form = Form.new(post, HTTP::Client.new("host")) 55 | 56 | form.to_html.should be_a(HtmlPrinter) 57 | end 58 | end 59 | 60 | describe "submit" do 61 | it "sends a reasonable request over http for v2" do 62 | time = Time.unix(1) 63 | Timecop.freeze(time) 64 | 65 | post = Post.new( 66 | region: "us-east-1", 67 | aws_access_key: "test", 68 | aws_secret_key: "test", 69 | signer: :v2 70 | ) 71 | 72 | post.build do |builder| 73 | builder.expiration(time) 74 | builder.condition("bucket", "test") 75 | builder.condition("key", "hi") 76 | end 77 | 78 | WebMock.stub(:post, "http://fake host/").to_return do |request| 79 | request.headers.not_nil!["Content-Type"].nil?.should eq false 80 | request.body.nil?.should eq false 81 | 82 | HTTP::Client::Response.new(200, body: "") 83 | end 84 | 85 | post.fields["Signature"].should eq("2OuTzB6hWfTJsU6UuN4mLuVEHpY=") 86 | 87 | client = HTTP::Client.new("fake host") 88 | io = IO::Memory.new("test") 89 | form = Form.new(post, client) 90 | form.submit(io) 91 | end 92 | 93 | it "sends a reasonable request over http for v4" do 94 | time = Time.unix(1) 95 | Timecop.freeze(time) 96 | 97 | post = Post.new( 98 | region: "us-east-1", 99 | aws_access_key: "test", 100 | aws_secret_key: "test" 101 | ) 102 | 103 | post.build do |builder| 104 | builder.expiration(time) 105 | builder.condition("bucket", "test") 106 | builder.condition("key", "hi") 107 | end 108 | 109 | WebMock.stub(:post, "http://fake host/").to_return do |request| 110 | request.headers.not_nil!["Content-Type"].nil?.should eq false 111 | request.body.nil?.should eq false 112 | 113 | HTTP::Client::Response.new(200, body: "") 114 | end 115 | 116 | post.fields["X-Amz-Signature"].should eq("db2a7f49b8c2b1513db3f85416ea04d38cb4a55b39f6c4a56b0bd83b41594a3d") 117 | 118 | client = HTTP::Client.new("fake host") 119 | io = IO::Memory.new("test") 120 | form = Form.new(post, client) 121 | form.submit(io) 122 | end 123 | end 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws 2 | 3 | Unofficial AWS SDK integration for Crystal. 4 | 5 | *Status*: This is still very much WIP (Work in Progress). 6 | 7 | ## Installation 8 | 9 | Add this to your application's `shard.yml`: 10 | 11 | ```yaml 12 | dependencies: 13 | aws: 14 | github: sdogruyol/aws 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### SQS 20 | 21 | ```crystal 22 | require "aws" 23 | 24 | KEY = "your-aws-key" 25 | SECRET = "your-aws-secret" 26 | REGION = "eu-west-1" 27 | 28 | client = Aws::Sqs::Client.new(REGION, KEY, SECRET) 29 | 30 | # Create a queue first 31 | client.create_queue("sqs-crystal") 32 | 33 | # Send a message to previously created queue 34 | client.send_message("sqs-crystal", "Hi from Crystal!") 35 | ``` 36 | 37 | ### S3 38 | 39 | ```crystal 40 | client = Aws::S3::Client.new("us-east1", "key", "secret") 41 | ``` 42 | 43 | For S3 compatible services, like DigitalOcean Spaces or Minio, you'll need to set a custom endpoint: 44 | 45 | ```crystal 46 | client = Aws::S3::Client.new("nyc3", "key", "secret", endpoint: "https://nyc3.digitaloceanspaces.com") 47 | ``` 48 | 49 | If you wish you wish to you version 2 request signing you may specify the signer 50 | 51 | ```crystal 52 | client = Aws::S3::Client.new("us-east1", "key", "secret", signer: :v2) 53 | ``` 54 | 55 | #### **List Buckets** 56 | 57 | ```crystal 58 | resp = client.list_buckets 59 | resp.buckets # => ["bucket1", "bucket2"] 60 | ``` 61 | 62 | #### **Delete a bucket** 63 | 64 | ```crystal 65 | client = Client.new("region", "key", "secret") 66 | resp = client.delete_bucket("test") 67 | resp # => true 68 | ``` 69 | 70 | #### Create a bucket 71 | 72 | ```crystal 73 | client = Client.new("region", "key", "secret") 74 | resp = client.create_bucket("test") 75 | resp # => true 76 | ``` 77 | 78 | #### **Put Object** 79 | 80 | ```crystal 81 | resp = client.put_object("bucket_name", "object_key", "myobjectbody") 82 | resp.etag # => ... 83 | ``` 84 | 85 | You can also pass additional headers (e.g. metadata): 86 | 87 | ```crystal 88 | client.put_object("bucket_name", "object_key", "myobjectbody", {"x-amz-meta-name" => "myobject"}) 89 | ``` 90 | 91 | #### **Delete Object** 92 | 93 | ```crystal 94 | resp = client.delete_object("bucket_name", "object_key") 95 | resp # => true 96 | ``` 97 | 98 | #### **Check Bucket Existence** 99 | 100 | ```crystal 101 | resp = client.head_bucket("bucket_name") 102 | resp # => true 103 | ``` 104 | 105 | Raises an exception if bucket does not exist. 106 | 107 | #### **Batch Delete Objects** 108 | 109 | ```crystal 110 | resp = client.batch_delete("bucket_name", ["key1", "key2"]) 111 | resp.success? # => true 112 | ``` 113 | 114 | #### **Get Object** 115 | 116 | ```crystal 117 | resp = client.put_object("bucket_name", "object_key") 118 | resp.body # => myobjectbody 119 | ``` 120 | 121 | #### **List Objects** 122 | 123 | ```crystal 124 | client.list_objects("bucket_name").each do |resp| 125 | p resp.contents.map(&.key) 126 | end 127 | ``` 128 | 129 | #### **Upload a file** 130 | 131 | ```crystal 132 | uploader = Aws::S3::FileUploader.new(client) 133 | 134 | File.open(File.expand_path("myfile"), "r") do |file| 135 | puts uploader.upload("bucket_name", "someobjectkey", file) 136 | end 137 | ``` 138 | 139 | You can also pass additional headers (e.g. metadata): 140 | 141 | ```crystal 142 | uploader = Aws::S3::FileUploader.new(client) 143 | 144 | File.open(File.expand_path("myfile"), "r") do |file| 145 | puts uploader.upload("bucket_name", "someobjectkey", file, {"x-amz-meta-name" => "myobject"}) 146 | end 147 | ``` 148 | 149 | #### **Creating a `Presigned::Form`.** 150 | 151 | ```crystal 152 | form = Aws::S3::Presigned::Form.build("us-east-1", "access key", "secret key") do |form| 153 | form.expiration(Time.utc_now.to_unix + 1000) 154 | form.condition("bucket", "mybucket") 155 | form.condition("acl", "public-read") 156 | form.condition("key", SecureRandom.uuid) 157 | form.condition("Content-Type", "text/plain") 158 | form.condition("success_action_status", "201") 159 | end 160 | ``` 161 | 162 | You may use version 2 request signing via 163 | 164 | ```crystal 165 | form = Aws::S3::Presigned::Form.build("us-east-1", "access key", "secret key", signer: :v2) do |form| 166 | ... 167 | end 168 | ``` 169 | 170 | **Converting the form to raw HTML (for browser uploads, etc).** 171 | 172 | ```crystal 173 | puts form.to_html 174 | ``` 175 | 176 | **Submitting the form.** 177 | 178 | ```crystal 179 | data = IO::Memory.new("Hello, S3!") 180 | form.submit(data) 181 | ``` 182 | 183 | #### **Creating a `Presigned::Url`.** 184 | 185 | ```crystal 186 | options = Aws::S3::Presigned::Url::Options.new( 187 | aws_access_key: "key", 188 | aws_secret_key: "secret", 189 | region: "us-east-1", 190 | object: "test.txt", 191 | bucket: "mybucket", 192 | additional_options: { 193 | "Content-Type" => "image/png" 194 | }) 195 | 196 | url = Aws::S3::Presigned::Url.new(options) 197 | p url.for(:put) 198 | ``` 199 | 200 | You may use version 2 request signing via 201 | 202 | 203 | ```crystal 204 | options = Aws::S3::Presigned::Url::Options.new( 205 | aws_access_key: "key", 206 | aws_secret_key: "secret", 207 | region: "us-east-1", 208 | object: "test.txt", 209 | bucket: "mybucket", 210 | signer: :v2 211 | ) 212 | ``` 213 | 214 | 215 | ### Contributing 216 | 217 | 1. Fork it () 218 | 2. Create your feature branch (`git checkout -b my-new-feature`) 219 | 3. Commit your changes (`git commit -am 'Add some feature'`) 220 | 4. Push to the branch (`git push origin my-new-feature`) 221 | 5. Create a new Pull Request 222 | 223 | ## Contributors 224 | 225 | - [sdogruyol](https://github.com/sdogruyol) Serdar Dogruyol - creator, maintainer 226 | 227 | ## Thanks 228 | 229 | Thanks to [@taylorfinnell](https://github.com/taylorfinnell) for his work on https://github.com/taylorfinnell/awscr-signer and https://github.com/taylorfinnell/awscr-s3. 230 | -------------------------------------------------------------------------------- /spec/s3/file_uploader_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws::S3 4 | describe FileUploader do 5 | describe "when the file is smaller than 5MB" do 6 | it "uploads it in one call" do 7 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object?") 8 | .with(body: "document") 9 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 10 | 11 | client = Client.new(REGION, "key", "secret") 12 | uploader = FileUploader.new(client) 13 | small_io = IO::Memory.new("document") 14 | 15 | uploader.upload("bucket", "object", small_io).should be_true 16 | end 17 | 18 | it "passes additional headers, when provided" do 19 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object?") 20 | .with(body: "document", headers: {"x-amz-meta-name" => "myobject"}) 21 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 22 | 23 | client = Client.new(REGION, "key", "secret") 24 | uploader = FileUploader.new(client) 25 | small_io = IO::Memory.new("document") 26 | 27 | uploader.upload("bucket", "object", small_io, {"x-amz-meta-name" => "myobject"}).should be_true 28 | end 29 | end 30 | 31 | describe "when the file is larger than 5MB" do 32 | it "uploads it in chunks" do 33 | # Start multipart upload 34 | WebMock.stub(:post, "http://s3.#{REGION}.amazonaws.com/bucket/object") 35 | .with(query: {"uploads" => ""}) 36 | .to_return(status: 200, body: Fixtures.start_multipart_upload_response(upload_id: "123")) 37 | 38 | # Upload part 1 39 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object") 40 | .with(query: {"partNumber" => "1", "uploadId" => "123"}) 41 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 42 | 43 | # Upload part 2 44 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object") 45 | .with(query: {"partNumber" => "2", "uploadId" => "123"}) 46 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 47 | 48 | # Complete multipart upload 49 | WebMock.stub(:post, "http://s3.#{REGION}.amazonaws.com/bucket/object?uploadId=123") 50 | .with(body: "\n1etag2etag\n") 51 | .to_return(status: 200, body: Fixtures.complete_multipart_upload_response) 52 | 53 | client = Client.new(REGION, "key", "secret") 54 | uploader = FileUploader.new(client) 55 | big_io = IO::Memory.new("a" * 5_500_000) 56 | 57 | uploader.upload("bucket", "object", big_io).should be_true 58 | end 59 | 60 | it "passes additional headers, when provided" do 61 | # Start multipart upload 62 | WebMock.stub(:post, "http://s3.#{REGION}.amazonaws.com/bucket/object") 63 | .with(query: {"uploads" => ""}, headers: {"x-amz-meta-name" => "myobject"}) 64 | .to_return(status: 200, body: Fixtures.start_multipart_upload_response(upload_id: "123")) 65 | 66 | # Upload part 1 67 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object") 68 | .with(query: {"partNumber" => "1", "uploadId" => "123"}) 69 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 70 | 71 | # Upload part 2 72 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object") 73 | .with(query: {"partNumber" => "2", "uploadId" => "123"}) 74 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 75 | 76 | # Complete multipart upload 77 | WebMock.stub(:post, "http://s3.#{REGION}.amazonaws.com/bucket/object?uploadId=123") 78 | .with(body: "\n1etag2etag\n") 79 | .to_return(status: 200, body: Fixtures.complete_multipart_upload_response) 80 | 81 | client = Client.new(REGION, "key", "secret") 82 | uploader = FileUploader.new(client) 83 | big_io = IO::Memory.new("a" * 5_500_000) 84 | 85 | uploader.upload("bucket", "object", big_io, {"x-amz-meta-name" => "myobject"}).should be_true 86 | end 87 | end 88 | 89 | describe "when the input is a file" do 90 | it "automatically assigns a content-type header" do 91 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object?") 92 | .with(body: "", headers: {"Content-Type" => "image/svg+xml"}) 93 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 94 | 95 | client = Client.new(REGION, "key", "secret") 96 | uploader = FileUploader.new(client) 97 | 98 | tempfile = File.tempfile(".svg") 99 | file = File.open(tempfile.path) 100 | 101 | uploader.upload("bucket", "object", file).should be_true 102 | tempfile.delete 103 | end 104 | 105 | it "doesn't assign a content-type header if config.with_content_types is false" do 106 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/bucket/object?") 107 | .to_return do |request| 108 | # Note: Make sure the Content-Type header isn't there 109 | request.headers.has_key?("Content-Type").should be_false 110 | 111 | headers = HTTP::Headers.new.merge!({"ETag" => "etag"}) 112 | HTTP::Client::Response.new(200, body: "", headers: headers) 113 | end 114 | 115 | client = Client.new(REGION, "key", "secret") 116 | options = FileUploader::Options.new(with_content_types: false) 117 | uploader = FileUploader.new(client, options) 118 | 119 | tempfile = File.tempfile(".svg") 120 | file = File.open(tempfile.path) 121 | 122 | uploader.upload("bucket", "object", file).should be_true 123 | tempfile.delete 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /spec/s3/presigned/post_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | module Aws 4 | module S3 5 | module Presigned 6 | describe Post do 7 | Spec.before_each do 8 | Timecop.freeze(Time.unix(1)) 9 | end 10 | 11 | Spec.after_each do 12 | Timecop.reset 13 | end 14 | 15 | describe "valid?" do 16 | it "returns true if bucket and policy are valid" do 17 | post = Post.new( 18 | region: "us-east-1", 19 | aws_access_key: "test", 20 | aws_secret_key: "test" 21 | ) 22 | post.build { |b| b.condition("bucket", "t"); b.expiration(Time.now) } 23 | 24 | post.valid?.should be_true 25 | end 26 | 27 | it "returns false if bucket is missing" do 28 | post = Post.new( 29 | region: "us-east-1", 30 | aws_access_key: "test", 31 | aws_secret_key: "test" 32 | ) 33 | post.build { |b| b.expiration(Time.now) } 34 | 35 | post.valid?.should be_false 36 | end 37 | 38 | it "returns false if policy is not valid" do 39 | post = Post.new( 40 | region: "us-east-1", 41 | aws_access_key: "test", 42 | aws_secret_key: "test" 43 | ) 44 | post.build { |b| b.condition("bucket", "t") } 45 | 46 | post.valid?.should be_false 47 | end 48 | end 49 | 50 | describe "fields" do 51 | it "generates the same fields each time" do 52 | time = Time.unix(1) 53 | post = Post.new( 54 | region: "us-east-1", 55 | aws_access_key: "test", 56 | aws_secret_key: "test", 57 | ) 58 | post.build { |b| b.condition("bucket", "t"); b.expiration(time) } 59 | 60 | post.fields.to_a.should eq(post.fields.to_a) 61 | end 62 | 63 | it "contains the policy field" do 64 | time = Time.unix(1) 65 | post = Post.new( 66 | region: "us-east-1", 67 | aws_access_key: "test", 68 | aws_secret_key: "test", 69 | ) 70 | post.build { |b| b.condition("bucket", "t"); b.expiration(time) } 71 | 72 | field = post.fields.select { |f| f.key == "policy" } 73 | (field.size > 0).should be_true 74 | field.first.value.should eq("eyJleHBpcmF0aW9uIjoiMTk3MC0wMS0wMVQwMDowMDowMS4wMDBaIiwiY29uZGl0aW9ucyI6W3siYnVja2V0IjoidCJ9LHsieC1hbXotY3JlZGVudGlhbCI6InRlc3QvMTk3MDAxMDEvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjoiQVdTNC1ITUFDLVNIQTI1NiJ9LHsieC1hbXotZGF0ZSI6IjE5NzAwMTAxVDAwMDAwMVoifV19") 75 | end 76 | 77 | it "contains the signature field" do 78 | time = Time.unix(1) 79 | post = Post.new( 80 | region: "us-east-1", 81 | aws_access_key: "test", 82 | aws_secret_key: "test" 83 | ) 84 | post.build { |b| b.condition("bucket", "t"); b.expiration(time) } 85 | 86 | field = post.fields.select { |f| f.key == "x-amz-signature" } 87 | (field.size > 0).should be_true 88 | field.first.value.should eq("c979e44c58c8df84951d121f7c66b62f2fbb3a2729dded7fd2708bdd763ff72e") 89 | end 90 | 91 | it "contains the credential field" do 92 | time = Time.unix(1) 93 | post = Post.new( 94 | region: "us-east-1", 95 | aws_access_key: "test", 96 | aws_secret_key: "test" 97 | ) 98 | post.build { |b| b.condition("bucket", "t"); b.expiration(time) } 99 | 100 | field = post.fields.select { |f| f.key == "x-amz-credential" } 101 | (field.size > 0).should be_true 102 | field.first.value.should eq("test/19700101/us-east-1/s3/aws4_request") 103 | end 104 | 105 | it "contains the algorithm field" do 106 | time = Time.unix(1) 107 | post = Post.new( 108 | region: "us-east-1", 109 | aws_access_key: "test", 110 | aws_secret_key: "test" 111 | ) 112 | post.build { |b| b.condition("bucket", "t"); b.expiration(time) } 113 | 114 | field = post.fields.select { |f| f.key == "x-amz-algorithm" } 115 | (field.size > 0).should be_true 116 | field.first.value.should eq(Awscr::Signer::ALGORITHM) 117 | end 118 | 119 | it "contains the date field" do 120 | time = Time.unix(1) 121 | post = Post.new( 122 | region: "us-east-1", 123 | aws_access_key: "test", 124 | aws_secret_key: "test" 125 | ) 126 | post.build { |b| b.condition("bucket", "t"); b.expiration(time) } 127 | 128 | field = post.fields.select { |f| f.key == "x-amz-date" } 129 | (field.size > 0).should be_true 130 | field.first.value.should eq("19700101T000001Z") 131 | end 132 | 133 | it "is a field collection" do 134 | post = Post.new( 135 | region: "us-east-1", 136 | aws_access_key: "test", 137 | aws_secret_key: "test" 138 | ) 139 | 140 | post.fields.should be_a(FieldCollection) 141 | end 142 | end 143 | 144 | describe "url" do 145 | it "raises if no bucket field" do 146 | post = Post.new( 147 | region: "us-east-1", 148 | aws_access_key: "test", 149 | aws_secret_key: "test" 150 | ) 151 | post.build { |b| b.expiration(Time.now) } 152 | 153 | expect_raises(Exception) do 154 | post.url 155 | end 156 | end 157 | 158 | it "includes the bucket field" do 159 | post = Post.new( 160 | region: "us-east-1", 161 | aws_access_key: "test", 162 | aws_secret_key: "test" 163 | ) 164 | post.build { |b| b.expiration(Time.now); b.condition("bucket", "test") } 165 | 166 | post.url.should eq("http://test.s3.amazonaws.com") 167 | end 168 | end 169 | 170 | describe "fields" do 171 | it "has fields" do 172 | post = Post.new( 173 | region: "us-east-1", 174 | aws_access_key: "test", 175 | aws_secret_key: "test" 176 | ) 177 | post.build { |b| b.expiration(Time.now) } 178 | 179 | post.fields.should be_a(FieldCollection) 180 | end 181 | end 182 | 183 | describe "build" do 184 | it "yields signed v2 policy" do 185 | post = Post.new( 186 | region: "us-east-1", 187 | aws_access_key: "test", 188 | aws_secret_key: "test", 189 | signer: :v2 190 | ) 191 | policy = nil 192 | post.build { |p| p.expiration(Time.now); policy = p } 193 | 194 | policy.should be_a(Policy) 195 | policy.as(Policy).fields["Signature"].should eq("vI0Km7fxOL7B9BunXFKM2/GvS1A=") 196 | end 197 | 198 | it "yields the signed v4 policy" do 199 | post = Post.new( 200 | region: "us-east-1", 201 | aws_access_key: "test", 202 | aws_secret_key: "test" 203 | ) 204 | policy = nil 205 | post.build { |p| p.expiration(Time.now); policy = p } 206 | 207 | policy.should be_a(Policy) 208 | policy.as(Policy).fields["x-amz-signature"].should eq("7dc0bf8fe1dcc2344f8ceaf3148a8898fbac6f074ccbe4edfbfac545be693add") 209 | end 210 | end 211 | end 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /spec/utils/http_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws 4 | module Utils 5 | SIGNER = Awscr::Signer::Signers::V4.new("blah", "blah", "blah", "blah") 6 | SERVICE_NAME = "aws-dummy" 7 | REGION = "eu-west-1" 8 | 9 | ERROR_BODY = <<-BODY 10 | 11 | 12 | NoSuchKey 13 | The resource you requested does not exist 14 | /mybucket/myfoto.jpg 15 | 4442587FB7D0A2F9 16 | 17 | BODY 18 | 19 | describe Http do 20 | describe "initialize" do 21 | it "sets the correct endpoint" do 22 | WebMock.stub(:get, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/") 23 | .to_return(status: 200) 24 | 25 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 26 | 27 | http.get("/").status_code.should eq 200 28 | end 29 | 30 | it "sets the correct endpoint with a defined region" do 31 | WebMock.stub(:get, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/") 32 | .to_return(status: 200) 33 | 34 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 35 | 36 | http.get("/").status_code.should eq 200 37 | end 38 | 39 | it "can set a custom endpoint" do 40 | WebMock.stub(:get, "https://nyc3.digitaloceanspaces.com") 41 | .to_return(status: 200) 42 | 43 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION, custom_endpoint: "https://nyc3.digitaloceanspaces.com") 44 | 45 | http.get("/").status_code.should eq 200 46 | end 47 | 48 | it "can set a custom endpoint with a port" do 49 | WebMock.stub(:get, "http://127.0.0.1:9000") 50 | .to_return(status: 200) 51 | 52 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION, custom_endpoint: "http://127.0.0.1:9000") 53 | 54 | http.get("/").status_code.should eq 200 55 | end 56 | end 57 | 58 | describe "get" do 59 | it "handles aws specific errors" do 60 | WebMock.stub(:get, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/sup?") 61 | .to_return(status: 404, body: ERROR_BODY) 62 | 63 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 64 | 65 | expect_raises Http::ServerError, "NoSuchKey: The resource you requested does not exist" do 66 | http.get("/sup") 67 | end 68 | end 69 | 70 | it "handles bad responses" do 71 | WebMock.stub(:get, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/sup?") 72 | .to_return(status: 404) 73 | 74 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 75 | 76 | expect_raises Http::ServerError do 77 | http.get("/sup") 78 | end 79 | end 80 | end 81 | 82 | describe "head" do 83 | it "handles aws specific errors" do 84 | WebMock.stub(:head, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 85 | .to_return(status: 404, body: ERROR_BODY) 86 | 87 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 88 | 89 | expect_raises Http::ServerError, "NoSuchKey: The resource you requested does not exist" do 90 | http.head("/") 91 | end 92 | end 93 | 94 | it "handles bad responses" do 95 | WebMock.stub(:head, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 96 | .to_return(status: 404) 97 | 98 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 99 | 100 | expect_raises Http::ServerError do 101 | http.head("/") 102 | end 103 | end 104 | end 105 | 106 | describe "put" do 107 | it "handles aws specific errors" do 108 | WebMock.stub(:put, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 109 | .to_return(status: 404, body: ERROR_BODY) 110 | 111 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 112 | 113 | expect_raises Http::ServerError, "NoSuchKey: The resource you requested does not exist" do 114 | http.put("/", "") 115 | end 116 | end 117 | 118 | it "handles bad responses" do 119 | WebMock.stub(:put, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 120 | .to_return(status: 404) 121 | 122 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 123 | 124 | expect_raises Http::ServerError do 125 | http.put("/", "") 126 | end 127 | end 128 | 129 | it "sets the Content-Length header by default" do 130 | WebMock.stub(:put, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/document") 131 | .with(body: "abcd", headers: {"Content-Length" => "4"}) 132 | .to_return(status: 200) 133 | 134 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 135 | http.put("/document", "abcd") 136 | end 137 | 138 | it "passes additional headers, when provided" do 139 | WebMock.stub(:put, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/document") 140 | .with(body: "abcd", headers: {"Content-Length" => "4", "x-amz-meta-name" => "document"}) 141 | .to_return(status: 200) 142 | 143 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 144 | http.put("/document", "abcd", {"x-amz-meta-name" => "document"}) 145 | end 146 | end 147 | 148 | describe "post" do 149 | it "passes additional headers, when provided" do 150 | WebMock.stub(:post, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 151 | .with(headers: {"x-amz-meta-name" => "document"}) 152 | 153 | Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION).post("/", headers: {"x-amz-meta-name" => "document"}) 154 | end 155 | 156 | it "handles aws specific errors" do 157 | WebMock.stub(:post, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 158 | .to_return(status: 404, body: ERROR_BODY) 159 | 160 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 161 | 162 | expect_raises Http::ServerError, "NoSuchKey: The resource you requested does not exist" do 163 | http.post("/") 164 | end 165 | end 166 | 167 | it "handles bad responses" do 168 | WebMock.stub(:post, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 169 | .to_return(status: 404) 170 | 171 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 172 | 173 | expect_raises Http::ServerError do 174 | http.post("/") 175 | end 176 | end 177 | end 178 | 179 | describe "delete" do 180 | it "passes additional headers, when provided" do 181 | WebMock.stub(:delete, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 182 | .with(headers: {"x-amz-mfa" => "123456"}) 183 | 184 | Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION).delete("/", headers: {"x-amz-mfa" => "123456"}) 185 | end 186 | 187 | it "handles aws specific errors" do 188 | WebMock.stub(:delete, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 189 | .to_return(status: 404, body: ERROR_BODY) 190 | 191 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 192 | 193 | expect_raises Http::ServerError, "NoSuchKey: The resource you requested does not exist" do 194 | http.delete("/") 195 | end 196 | end 197 | 198 | it "handles bad responses" do 199 | WebMock.stub(:delete, "http://#{SERVICE_NAME}.#{REGION}.amazonaws.com/?") 200 | .to_return(status: 404) 201 | 202 | http = Http.new(SIGNER, service_name: SERVICE_NAME, region: REGION) 203 | 204 | expect_raises Http::ServerError do 205 | http.delete("/") 206 | end 207 | end 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /src/aws/s3/client.cr: -------------------------------------------------------------------------------- 1 | require "./responses/*" 2 | require "./paginators/*" 3 | require "uri" 4 | require "xml/builder" 5 | require "digest" 6 | require "base64" 7 | 8 | module Aws::S3 9 | # An S3 client for interacting with S3. 10 | # 11 | # Creating an S3 Client 12 | # 13 | # ``` 14 | # client = Client.new("region", "key", "secret") 15 | # ``` 16 | # 17 | # Client with custom endpoint 18 | # ``` 19 | # client = Client.new("region", "key", "secret", endpoint: "http://test.com") 20 | # ``` 21 | # 22 | # Client with custom signer algorithm 23 | # ``` 24 | # client = Client.new("region", "key", "secret", signer: :v2) 25 | # ``` 26 | class Client 27 | @signer : Awscr::Signer::Signers::Interface 28 | 29 | def initialize(@region : String, @aws_access_key : String, @aws_secret_key : String, @endpoint : String? = nil, signer : Symbol = :v4) 30 | @signer = Utils::SignerFactory.get( 31 | service_name: Aws::S3::SERVICE_NAME, 32 | version: signer, 33 | region: @region, 34 | aws_access_key: @aws_access_key, 35 | aws_secret_key: @aws_secret_key 36 | ) 37 | end 38 | 39 | # List s3 buckets 40 | # 41 | # ``` 42 | # client = Client.new("region", "key", "secret") 43 | # resp = client.list_buckets 44 | # p resp.buckets.map(&.name) # => ["bucket1", "bucket2"] 45 | # ``` 46 | def list_buckets 47 | resp = http.get("/") 48 | 49 | Response::ListAllMyBuckets.from_response(resp) 50 | end 51 | 52 | # Create a bucket, optionally place it in a region. 53 | # 54 | # ``` 55 | # client = Client.new("region", "key", "secret") 56 | # resp = client.create_bucket("test") 57 | # p resp # => true 58 | # ``` 59 | def put_bucket(bucket, region : String? = nil, headers : Hash(String, String) = Hash(String, String).new) 60 | body = if region 61 | ::XML.build do |xml| 62 | xml.element("CreateBucketConfiguration") do 63 | xml.element("LocationConstraint") do 64 | xml.text(region.to_s) 65 | end 66 | end 67 | end 68 | end 69 | 70 | resp = http.put("/#{bucket}", body: body.to_s, headers: headers) 71 | 72 | resp.status_code == 200 73 | end 74 | 75 | # Delete a bucket, note: it must be empty 76 | # 77 | # ``` 78 | # client = Client.new("region", "key", "secret") 79 | # resp = client.delete_bucket("test") 80 | # p resp # => true 81 | # ``` 82 | def delete_bucket(bucket) 83 | resp = http.delete("/#{bucket}") 84 | 85 | resp.status_code == 204 86 | end 87 | 88 | # Start a multipart upload 89 | # 90 | # ``` 91 | # client = Client.new("region", "key", "secret") 92 | # resp = client.start_multipart_upload("bucket1", "obj") 93 | # p resp.upload_id # => someid 94 | # ``` 95 | def start_multipart_upload(bucket : String, object : String, 96 | headers : Hash(String, String) = Hash(String, String).new) 97 | resp = http.post("/#{bucket}/#{object}?uploads", headers: headers) 98 | 99 | Response::StartMultipartUpload.from_response(resp) 100 | end 101 | 102 | # Upload a part, for use in multipart uploading 103 | # 104 | # ``` 105 | # client = Client.new("region", "key", "secret") 106 | # resp = client.upload_part("bucket1", "obj", "someid", 123, "MY DATA") 107 | # p resp.upload_id # => someid 108 | # ``` 109 | def upload_part(bucket : String, object : String, 110 | upload_id : String, part_number : Int32, part : IO | String) 111 | resp = http.put("/#{bucket}/#{object}?partNumber=#{part_number}&uploadId=#{upload_id}", part) 112 | 113 | Response::UploadPartOutput.new( 114 | resp.headers["ETag"], 115 | part_number, 116 | upload_id 117 | ) 118 | end 119 | 120 | # Complete a multipart upload 121 | # 122 | # ``` 123 | # client = Client.new("region", "key", "secret") 124 | # resp = client.complete_multipart_upload("bucket1", "obj", "123", parts) 125 | # p resp.key # => obj 126 | # ``` 127 | def complete_multipart_upload(bucket : String, object : String, upload_id : String, parts : Array(Response::UploadPartOutput)) 128 | body = ::XML.build do |xml| 129 | xml.element("CompleteMultipartUpload") do 130 | parts.each do |output| 131 | xml.element("Part") do 132 | xml.element("PartNumber") do 133 | xml.text(output.part_number.to_s) 134 | end 135 | 136 | xml.element("ETag") do 137 | xml.text(output.etag) 138 | end 139 | end 140 | end 141 | end 142 | end 143 | 144 | resp = http.post("/#{bucket}/#{object}?uploadId=#{upload_id}", body: body) 145 | Response::CompleteMultipartUpload.from_response(resp) 146 | end 147 | 148 | # Aborts a multi part upload. Returns true if the abort was a success, false 149 | # otherwise. 150 | # 151 | # ``` 152 | # client = Client.new("region", "key", "secret") 153 | # resp = client.abort_multipart_upload("bucket1", "obj", "123") 154 | # p resp # => true 155 | # ``` 156 | def abort_multipart_upload(bucket : String, object : String, upload_id : String) 157 | resp = http.delete("/#{bucket}/#{object}?uploadId=#{upload_id}") 158 | 159 | resp.status_code == 204 160 | end 161 | 162 | # Get information about a bucket, useful for determining if a bucket exists. 163 | # Raises a `Http::ServerError` if the bucket does not exist. 164 | # 165 | # ``` 166 | # client = Client.new("region", "key", "secret") 167 | # resp = client.head_bucket("bucket1") 168 | # p resp # => true 169 | # ``` 170 | def head_bucket(bucket) 171 | http.head("/#{bucket}") 172 | 173 | true 174 | end 175 | 176 | # Delete an object from a bucket, returns `true` if successful, `false` 177 | # otherwise. 178 | # 179 | # ``` 180 | # client = Client.new("region", "key", "secret") 181 | # resp = client.delete_object("bucket1", "obj") 182 | # p resp # => true 183 | # ``` 184 | def delete_object(bucket, object, headers : Hash(String, String) = Hash(String, String).new) 185 | resp = http.delete("/#{bucket}/#{object}", headers) 186 | 187 | resp.status_code == 204 188 | end 189 | 190 | # Batch deletes a list of object keys in a single request. 191 | # 192 | # ``` 193 | # client = Client.new("region", "key", "secret") 194 | # resp = client.batch_delete("bucket1", ["obj", "obj2"]) 195 | # p resp.success? # => true 196 | # ``` 197 | def batch_delete(bucket, keys : Array(String)) 198 | raise "More than 1000 keys is not yet supported." if keys.size > 1_000 199 | 200 | body = ::XML.build do |xml| 201 | xml.element("Delete") do 202 | keys.each do |key| 203 | xml.element("Object") do 204 | xml.element("Key") do 205 | xml.text(key) 206 | end 207 | end 208 | end 209 | end 210 | end 211 | 212 | headers = { 213 | "Content-MD5" => Base64.strict_encode(Digest::MD5.digest(body)), 214 | "Content-Length" => body.bytesize.to_s, 215 | } 216 | 217 | resp = http.post("/#{bucket}?delete", body: body, headers: headers) 218 | 219 | Response::BatchDeleteOutput.from_response(resp) 220 | end 221 | 222 | # Add an object to a bucket. 223 | # 224 | # ``` 225 | # client = Client.new("region", "key", "secret") 226 | # resp = client.put_object("bucket1", "obj", "MY DATA") 227 | # p resp.key # => "obj" 228 | # ``` 229 | def put_object(bucket, object : String, body : IO | String, 230 | headers : Hash(String, String) = Hash(String, String).new) 231 | resp = http.put("/#{bucket}/#{object}", body, headers) 232 | 233 | Response::PutObjectOutput.from_response(resp) 234 | end 235 | 236 | # Get the contents of an object in a bucket 237 | # 238 | # ``` 239 | # client = Client.new("region", "key", "secret") 240 | # resp = client.get_object("bucket1", "obj") 241 | # p resp.body # => "MY DATA" 242 | # ``` 243 | def get_object(bucket, object : String) 244 | resp = http.get("/#{bucket}/#{object}") 245 | 246 | Response::GetObjectOutput.from_response(resp) 247 | end 248 | 249 | # List all the items in a bucket 250 | # 251 | # ``` 252 | # client = Client.new("region", "key", "secret") 253 | # resp = client.list_objects("bucket1", prefix: "test") 254 | # p resp.map(&.key) # => ["obj"] 255 | # ``` 256 | def list_objects(bucket, max_keys = nil, prefix = nil) 257 | params = { 258 | "bucket" => bucket, 259 | "list-type" => "2", 260 | "max-keys" => max_keys.to_s, 261 | "prefix" => prefix.to_s, 262 | } 263 | 264 | Paginator::ListObjectsV2.new(http, params) 265 | end 266 | 267 | # :nodoc: 268 | private def http 269 | Utils::Http.new(signer: @signer, region: @region, custom_endpoint: @endpoint, service_name: Aws::S3::SERVICE_NAME) 270 | end 271 | end 272 | end 273 | -------------------------------------------------------------------------------- /spec/s3/client_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | module Aws::S3 4 | REGION = "eu-west-1" 5 | 6 | describe Client do 7 | it "allows signer version" do 8 | Client.new("adasd", "adasd", "adad", signer: :v2) 9 | end 10 | 11 | describe "batch_delete" do 12 | it "can can fail to delete items" do 13 | xml = <<-XML 14 | 15 | 16 | 17 | testkey 18 | AccessDenied 19 | Access Denied 20 | 21 | 22 | XML 23 | 24 | WebMock.stub(:post, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket?delete") 25 | .with(body: "\ntestkey\n", headers: {"Content-MD5" => "Slga5acph5mH0Gagq5P2BQ==", "Content-Length" => "75"}) 26 | .to_return(body: xml) 27 | 28 | client = Client.new(REGION, "key", "secret") 29 | result = client.batch_delete("bucket", ["testkey"]) 30 | 31 | result.should be_a(Response::BatchDeleteOutput) 32 | result.success?.should be_false 33 | result.deleted_objects.should eq([] of Response::BatchDeleteOutput::DeletedObject) 34 | result.failed_objects.should eq([Response::BatchDeleteOutput::DeletedObject.new("testkey", "AccessDenied", "Access Denied")]) 35 | end 36 | 37 | it "can can successfully delete items" do 38 | xml = <<-XML 39 | 40 | 41 | 42 | testkey 43 | 44 | 45 | XML 46 | 47 | WebMock.stub(:post, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket?delete") 48 | .with(body: "\ntestkey\n", headers: {"Content-MD5" => "Slga5acph5mH0Gagq5P2BQ==", "Content-Length" => "75"}) 49 | .to_return(body: xml) 50 | 51 | client = Client.new(REGION, "key", "secret") 52 | result = client.batch_delete("bucket", ["testkey"]) 53 | 54 | result.should be_a(Response::BatchDeleteOutput) 55 | result.success?.should be_true 56 | result.failed_objects.should eq([] of Response::BatchDeleteOutput::DeletedObject) 57 | result.deleted_objects.should eq([Response::BatchDeleteOutput::DeletedObject.new("testkey", "", "")]) 58 | end 59 | 60 | it "raises if too many keys" do 61 | keys = ["test"] * 1000 62 | 63 | client = Client.new(REGION, "key", "secret") 64 | 65 | expect_raises Exception do 66 | client.batch_delete("bucket", keys) 67 | end 68 | end 69 | end 70 | 71 | describe "put_bucket" do 72 | it "creates a bucket" do 73 | WebMock.stub(:put, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket") 74 | .to_return(body: "") 75 | 76 | client = Client.new(REGION, "key", "secret") 77 | result = client.put_bucket("bucket") 78 | 79 | result.should be_true 80 | end 81 | 82 | it "can create a bucket with a region" do 83 | body = "\nus-west-2\n" 84 | 85 | WebMock.stub(:put, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket2") 86 | .with(body: body) 87 | .to_return(body: "") 88 | 89 | client = Client.new(REGION, "key", "secret") 90 | result = client.put_bucket("bucket2", region: "us-west-2") 91 | 92 | result.should be_true 93 | end 94 | end 95 | 96 | describe "delete_bucket" do 97 | it "returns true when buckest is deleted" do 98 | WebMock.stub(:delete, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket") 99 | .to_return(body: "", status: 204) 100 | 101 | client = Client.new(REGION, "key", "secret") 102 | result = client.delete_bucket("bucket") 103 | 104 | result.should be_true 105 | end 106 | end 107 | 108 | describe "abort_multipart_upload" do 109 | it "aborts an upload" do 110 | WebMock.stub(:delete, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket/object?uploadId=upload_id") 111 | .to_return(status: 204) 112 | 113 | client = Client.new(REGION, "key", "secret") 114 | result = client.abort_multipart_upload("bucket", "object", "upload_id") 115 | 116 | result.should be_true 117 | end 118 | end 119 | 120 | describe "start_multipart_upload" do 121 | it "starts a multipart upload" do 122 | WebMock.stub(:post, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket/object?uploads") 123 | .to_return(status: 200, body: Fixtures.start_multipart_upload_response) 124 | 125 | client = Client.new(REGION, "key", "secret") 126 | result = client.start_multipart_upload("bucket", "object") 127 | 128 | result.should eq(Response::StartMultipartUpload.new( 129 | "bucket", 130 | "object", 131 | "FxtGq8otGhDtYJa5VYLpPOBQo2niM2a1YR8wgcwqHJ1F1Djflj339mEfpm7NbYOoIg.6bIPeXl2RB82LuAnUkTQUEz_ReIu2wOwawGc0Z4SLERxoXospqANXDazuDmRF" 132 | )) 133 | end 134 | 135 | it "passes additional headers, when provided" do 136 | WebMock.stub(:post, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket/object?uploads") 137 | .with(headers: {"x-amz-meta-name" => "document"}) 138 | .to_return(status: 200, body: Fixtures.start_multipart_upload_response) 139 | 140 | client = Client.new(REGION, "key", "secret") 141 | client.start_multipart_upload("bucket", "object", headers: {"x-amz-meta-name" => "document"}) 142 | end 143 | end 144 | 145 | describe "upload_part" do 146 | it "uploads a part" do 147 | WebMock.stub(:put, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket2/obj2?partNumber=1&uploadId=123") 148 | .with(body: "test") 149 | .to_return(status: 200, body: "", headers: {"ETag" => "etag"}) 150 | 151 | client = Client.new(REGION, "key", "secret") 152 | result = client.upload_part("bucket2", "obj2", "123", 1, "test") 153 | 154 | result.should eq( 155 | Response::UploadPartOutput.new("etag", 1, "123") 156 | ) 157 | end 158 | end 159 | 160 | describe "complete_multipart_upload" do 161 | it "completes a multipart upload" do 162 | post_body = "\n1etag\n" 163 | 164 | WebMock.stub(:post, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket/object?uploadId=upload_id") 165 | .with(body: post_body) 166 | .to_return(status: 200, body: Fixtures.complete_multipart_upload_response) 167 | 168 | outputs = [Response::UploadPartOutput.new("etag", 1, "upload_id")] 169 | 170 | client = Client.new(REGION, "key", "secret") 171 | result = client.complete_multipart_upload("bucket", "object", "upload_id", outputs) 172 | 173 | result.should eq( 174 | Response::CompleteMultipartUpload.new( 175 | "http://s3.amazonaws.com/screensnapr-development/test", 176 | "test", 177 | "\"7611c6414e4b58f22ff9f59a2c1767b7-2\"" 178 | ) 179 | ) 180 | end 181 | end 182 | 183 | describe "delete_object" do 184 | it "returns true if object deleted" do 185 | WebMock.stub(:delete, "http://s3.#{REGION}.amazonaws.com/blah/obj?") 186 | .to_return(status: 204) 187 | 188 | client = Client.new(REGION, "key", "secret") 189 | result = client.delete_object("blah", "obj") 190 | 191 | result.should be_true 192 | end 193 | 194 | it "passes additional headers, when provided" do 195 | WebMock.stub(:delete, "http://s3.#{REGION}.amazonaws.com/blah/obj?") 196 | .with(headers: {"x-amz-mfa" => "123456"}) 197 | .to_return(status: 204) 198 | 199 | client = Client.new(REGION, "key", "secret") 200 | client.delete_object("blah", "obj", headers: {"x-amz-mfa" => "123456"}) 201 | end 202 | end 203 | 204 | describe "put_object" do 205 | it "can do a basic put" do 206 | io = IO::Memory.new("Hello") 207 | 208 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/mybucket/object.txt") 209 | .with(body: "Hello") 210 | .to_return(body: "", headers: {"ETag" => "etag"}) 211 | 212 | client = Client.new(REGION, "key", "secret") 213 | resp = client.put_object("mybucket", "object.txt", io) 214 | 215 | resp.should eq(Response::PutObjectOutput.new("etag")) 216 | end 217 | 218 | it "passes additional headers, when provided" do 219 | io = IO::Memory.new("Hello") 220 | 221 | WebMock.stub(:put, "http://s3.#{REGION}.amazonaws.com/mybucket/object.txt") 222 | .with(body: "Hello", headers: {"Content-Length" => "5", "x-amz-meta-name" => "object"}) 223 | .to_return(body: "", headers: {"ETag" => "etag"}) 224 | 225 | client = Client.new(REGION, "key", "secret") 226 | client.put_object("mybucket", "object.txt", io, {"x-amz-meta-name" => "object"}) 227 | end 228 | end 229 | 230 | describe "list_objects" do 231 | it "handles pagination" do 232 | resp = <<-RESP 233 | 234 | 235 | bucket 236 | 237 | 1 238 | 1 239 | true 240 | token 242 | my-image.jpg 243 | 2009-10-12T17:50:30.000Z 244 | "fba9dede5f27731c9771645a39863328" 245 | 434234 246 | STANDARD 247 | 248 | 249 | RESP 250 | 251 | resp2 = <<-RESP 252 | 253 | 254 | bucket 255 | 256 | 1 257 | 1 258 | false 259 | 260 | key2 261 | 2009-10-12T17:50:30.000Z 262 | "fba9dede5f27731c9771645a39863329" 263 | 1337 264 | STANDARD 265 | 266 | 267 | RESP 268 | 269 | WebMock.stub(:get, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket?list-type=2&max-keys=1&continuation-token=token") 270 | .to_return(body: resp2) 271 | 272 | WebMock.stub(:get, "http://#{Aws::S3::SERVICE_NAME}.#{REGION}.amazonaws.com/bucket?list-type=2&max-keys=1") 273 | .to_return(body: resp) 274 | 275 | client = Client.new(REGION, "key", "secret") 276 | 277 | objects = [] of Response::ListObjectsV2 278 | client.list_objects("bucket", max_keys: 1).each do |output| 279 | objects << output 280 | end 281 | 282 | expected_objects = [ 283 | Object.new("my-image.jpg", 434_234, 284 | "\"fba9dede5f27731c9771645a39863328\""), 285 | Object.new("key2", 1337, 286 | "\"fba9dede5f27731c9771645a39863329\""), 287 | ] 288 | 289 | objects.should eq([ 290 | Response::ListObjectsV2.new("bucket", "", 1, 1, true, "token", 291 | [expected_objects.first]), 292 | Response::ListObjectsV2.new("bucket", "", 1, 1, false, "", 293 | [expected_objects.last]), 294 | ]) 295 | end 296 | 297 | it "supports basic case" do 298 | resp = <<-RESP 299 | 300 | 301 | blah 302 | 303 | 205 304 | 1000 305 | false 306 | 307 | my-image.jpg 308 | 2009-10-12T17:50:30.000Z 309 | "fba9dede5f27731c9771645a39863328" 310 | 434234 311 | STANDARD 312 | 313 | 314 | key2 315 | 2009-10-12T17:50:30.000Z 316 | "fba9dede5f27731c9771645a39863329" 317 | 1337 318 | STANDARD 319 | 320 | 321 | RESP 322 | 323 | WebMock.stub(:get, "http://s3.#{REGION}.amazonaws.com/blah?list-type=2") 324 | .to_return(body: resp) 325 | 326 | expected_objects = [ 327 | Object.new("my-image.jpg", 434_234, 328 | "\"fba9dede5f27731c9771645a39863328\""), 329 | Object.new("key2", 1337, 330 | "\"fba9dede5f27731c9771645a39863329\""), 331 | ] 332 | 333 | client = Client.new(REGION, "key", "secret") 334 | 335 | client.list_objects("blah").each do |output| 336 | output.should eq(Response::ListObjectsV2.new("blah", "", 205, 1000, false, "", expected_objects)) 337 | end 338 | end 339 | end 340 | 341 | describe "list_buckets" do 342 | it "returns buckets on success" do 343 | resp = <<-RESP 344 | 345 | 346 | 347 | bcaf1ffd86f461ca5fb16fd081034f 348 | webfile 349 | 350 | 351 | 352 | quotes 353 | 2006-02-03T16:45:09.000Z 354 | 355 | 356 | 357 | RESP 358 | 359 | WebMock.stub(:get, "http://s3.#{REGION}.amazonaws.com/?") 360 | .to_return(body: resp) 361 | 362 | client = Client.new(REGION, "key", "secret") 363 | output = client.list_buckets 364 | 365 | output.should eq(Response::ListAllMyBuckets.new([ 366 | Bucket.new("quotes", Time.parse_utc("2006-02-03T16:45:09 +00:00", Response::ListAllMyBuckets::DATE_FORMAT)), 367 | ])) 368 | end 369 | end 370 | 371 | describe "head_bucket" do 372 | it "raises if bucket does not exist" do 373 | WebMock.stub(:head, "http://s3.#{REGION}.amazonaws.com/blah2?") 374 | .to_return(status: 404) 375 | 376 | client = Client.new(REGION, "key", "secret") 377 | 378 | expect_raises(Exception) do 379 | client.head_bucket("blah2") 380 | end 381 | end 382 | 383 | it "returns true if bucket exists" do 384 | WebMock.stub(:head, "http://s3.#{REGION}.amazonaws.com/blah?") 385 | .to_return(status: 200) 386 | 387 | client = Client.new(REGION, "key", "secret") 388 | result = client.head_bucket("blah") 389 | 390 | result.should be_true 391 | end 392 | end 393 | 394 | describe "custom endpoint" do 395 | it "can set a custom endpoint" do 396 | io = IO::Memory.new("Hello") 397 | 398 | WebMock.stub(:put, "https://nyc3.digitaloceanspaces.com/mybucket/object.txt") 399 | .with(body: "Hello") 400 | .to_return(body: "", headers: {"ETag" => "etag"}) 401 | 402 | client = Client.new("", "key", "secret", "https://nyc3.digitaloceanspaces.com") 403 | resp = client.put_object("mybucket", "object.txt", io) 404 | 405 | resp.should eq(Response::PutObjectOutput.new("etag")) 406 | end 407 | 408 | it "can set a custom endpoint with a port" do 409 | io = IO::Memory.new("Hello") 410 | 411 | WebMock.stub(:put, "http://127.0.0.1:9000/mybucket/object.txt") 412 | .with(body: "Hello") 413 | .to_return(body: "", headers: {"ETag" => "etag"}) 414 | 415 | client = Client.new("", "key", "secret", "http://127.0.0.1:9000") 416 | resp = client.put_object("mybucket", "object.txt", io) 417 | 418 | resp.should eq(Response::PutObjectOutput.new("etag")) 419 | end 420 | end 421 | end 422 | end 423 | --------------------------------------------------------------------------------