diff --git a/.gitignore b/.gitignore index 14e3ed5..805ecb5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +/.env ## Specific to RubyMotion: .dat* @@ -71,3 +72,6 @@ bower.json # Ignore pow environment settings .powenv + +# Ignore application configuration +/config/application.yml diff --git a/Gemfile b/Gemfile index c80774c..44a27e9 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,6 @@ gem 'active_shipping' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' gem 'rails', '4.2.5' # Use sqlite3 as the database for Active Record -gem 'sqlite3' # Use SCSS for stylesheets gem 'sass-rails', '~> 5.0' # Use Uglifier as compressor for JavaScript assets @@ -22,7 +21,7 @@ gem 'turbolinks' gem 'jbuilder', '~> 2.0' # bundle exec rake doc:rails generates the API under doc/api. gem 'sdoc', '~> 0.4.0', group: :doc - +gem "figaro" # Use ActiveModel has_secure_password # gem 'bcrypt', '~> 3.1.7' @@ -31,15 +30,21 @@ gem 'sdoc', '~> 0.4.0', group: :doc # Use Capistrano for deployment # gem 'capistrano-rails', group: :development +group :production do + gem 'pg' +end group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug' gem 'simplecov', require: false gem 'factory_girl_rails', '~> 4.0' + gem 'pry' + gem 'curl' end group :development do + gem 'sqlite3' # Access an IRB console on exception pages or by using <%= console %> in views gem 'web-console', '~> 2.0' diff --git a/Gemfile.lock b/Gemfile.lock index c124afc..772861a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -45,6 +45,7 @@ GEM thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) arel (6.0.3) + awesome_print (1.6.1) better_errors (2.1.1) coderay (>= 1.0.0) erubis (>= 2.6.6) @@ -62,6 +63,9 @@ GEM execjs coffee-script-source (1.10.0) concurrent-ruby (1.0.0) + curl (0.0.9) + awesome_print (>= 0.2.1) + unidecoder (>= 1.1.1) debug_inspector (0.0.2) diff-lcs (1.2.5) docile (1.1.5) @@ -72,6 +76,8 @@ GEM factory_girl_rails (4.5.0) factory_girl (~> 4.5.0) railties (>= 3.0.0) + figaro (1.1.1) + thor (~> 0.14) globalid (0.3.6) activesupport (>= 4.1.0) i18n (0.7.0) @@ -87,12 +93,18 @@ GEM nokogiri (>= 1.5.9) mail (2.6.3) mime-types (>= 1.16, < 3) + method_source (0.8.2) mime-types (2.99) mini_portile2 (2.0.0) minitest (5.8.3) multi_json (1.11.2) nokogiri (1.6.7.1) mini_portile2 (~> 2.0.0.rc2) + pg (0.18.4) + pry (0.10.3) + coderay (~> 1.1.0) + method_source (~> 0.8.1) + slop (~> 3.4) quantified (1.0.1) rack (1.6.4) rack-test (0.6.3) @@ -156,6 +168,7 @@ GEM json (~> 1.8) simplecov-html (~> 0.10.0) simplecov-html (0.10.0) + slop (3.6.0) spring (1.6.2) sprockets (3.5.2) concurrent-ruby (~> 1.0) @@ -175,6 +188,7 @@ GEM uglifier (2.7.2) execjs (>= 0.3.0) json (>= 1.8.0) + unidecoder (1.1.2) web-console (2.2.1) activemodel (>= 4.0) binding_of_caller (>= 0.7.2) @@ -190,9 +204,13 @@ DEPENDENCIES binding_of_caller byebug coffee-rails (~> 4.1.0) + curl factory_girl_rails (~> 4.0) + figaro jbuilder (~> 2.0) jquery-rails + pg + pry rails (= 4.2.5) rspec-rails (~> 3.0) sass-rails (~> 5.0) diff --git a/README.md b/README.md index f1667f4..34113fb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,28 @@ -# Shipping Service API +# Shipping Service API by Desiree and Audrey + +see progress here: +https://trello.com/b/TXJ6uwTM/shippingservice + +This API was designed for somewhat general use, but our assigned marketplace was Wetsy. (http://wetsy.herokuapp.com/) +The fork of the wetsy repo we worked on is here: https://github.com/mangolatte/betsy/tree/alddfp/master` + +Our project was a little cut short by the shortened week, so we did not accomplish all we had wanted to. + +#Accomplished:# +-98.4% test coverage +-On localhost with two ports, able to test using API with wetsy, get shipping estimates, and add shipping cost to the order total. +-In an order from wetsy, items from different merchants are packaged together using a packing method in this api. That way, when you order multiple items from the same merchant they are packaged together, and these packages are sent from various origins to one destination. +-Some error handling for when Active shipping would not return shipping info (because of problems with the zip codes, etc.) + +#Did not accomplish:# +-In retrospect, wish we had added destination to EACH package instead of having one destination. Having one destination was great for wetsy, but the API would be more generalized if it allowed for origin and destination for each package. +- Usernames/passwords for ups and usps are hardcoded, which is ok because they're already publicly on github but not ideal as far as best practices. +-Test coverage is high, but tests themselves are not thorough. +-Deploy shipping service on heroku (left off trying to get secrets to work properly) + + + + Build a stand-alone shipping service API that calculates estimated shipping cost for an order from another team's bEtsy application. ## Learning Goals diff --git a/app/assets/javascripts/estimates.coffee b/app/assets/javascripts/estimates.coffee new file mode 100644 index 0000000..24f83d1 --- /dev/null +++ b/app/assets/javascripts/estimates.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/stylesheets/estimates.scss b/app/assets/stylesheets/estimates.scss new file mode 100644 index 0000000..602fef5 --- /dev/null +++ b/app/assets/stylesheets/estimates.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the estimates controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/controllers/estimates_controller.rb b/app/controllers/estimates_controller.rb new file mode 100644 index 0000000..ca78e10 --- /dev/null +++ b/app/controllers/estimates_controller.rb @@ -0,0 +1,23 @@ + +class EstimatesController < ApplicationController + require 'active_shipping' + require "./lib/estimator/estimator.rb" + + def quote + ship_params = strong_shipping_params + estimate = Estimator::Estimate.query(ship_params) + if estimate + render :json => estimate.to_json, :status => :ok + else + render :json => [], :status => :no_content + end + end + +private + + def strong_shipping_params + params.require(:shipping_params).permit(:destination => [:country, :state, :city, :postal_code], :packages => {:origin => [:country, :state, :city, :postal_code], :package_items => [:weight, :height, :length, :width]}) + end + + +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..6e9385e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,4 @@ module ApplicationHelper + + end diff --git a/app/helpers/estimates_helper.rb b/app/helpers/estimates_helper.rb new file mode 100644 index 0000000..019d7a6 --- /dev/null +++ b/app/helpers/estimates_helper.rb @@ -0,0 +1,2 @@ +module EstimatesHelper +end diff --git a/config/application.rb b/config/application.rb index 84b564d..b2c781c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -17,6 +17,9 @@ module ShippingService class Application < Rails::Application + config.autoload_paths += %W(#{config.root}/lib/) + Rails.application.config.secret_key_base = ENV["SECRET_KEY_BASE"] + Rails.application.config.secret_token = ENV["SECRET_TOKEN"] # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. diff --git a/config/environments/production.rb b/config/environments/production.rb index 5c1b32e..10a93dd 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -29,7 +29,7 @@ # config.assets.css_compressor = :sass # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false + config.assets.compile = true # Asset digests allow you to set far-future HTTP expiration dates on all assets, # yet still be able to expire them through the digest params. diff --git a/config/routes.rb b/config/routes.rb index 3f66539..1078b65 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,18 +1,5 @@ Rails.application.routes.draw do - # The priority is based upon order of creation: first created -> highest priority. - # See how all your routes lay out with "rake routes". - - # You can have the root of your site routed with "root" - # root 'welcome#index' - - # Example of regular route: - # get 'products/:id' => 'catalog#view' - - # Example of named route that can be invoked with purchase_url(id: product.id) - # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase - - # Example resource route (maps HTTP verbs to controller actions automatically): - # resources :products + get '/quote' => 'estimates#quote' # Example resource route with options: # resources :products do diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..4dfbb16 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,16 @@ +# encoding: UTF-8 +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 0) do + +end diff --git a/lib/estimator/estimator.rb b/lib/estimator/estimator.rb new file mode 100644 index 0000000..edbcbaa --- /dev/null +++ b/lib/estimator/estimator.rb @@ -0,0 +1,89 @@ +module Estimator + class Estimate < ActiveRecord::Base + require 'active_shipping' + + def self.query(ship_params) + @api_call_ok = true + ship_array = shipment_array(ship_params) + ups_rates = get_rates(ship_array, ups) + usps_rates = get_rates(ship_array, usps) + quote = assemble_quote(ups_rates, usps_rates) + if @api_call_ok + return quote + else + return nil + end + @api_call_ok = true + end + + def self.ups + ActiveShipping::UPS.new(login: 'shopifolk', password: 'Shopify_rocks', key: '7CE85DED4C9D07AB') + end + + def self.usps + ActiveShipping::USPS.new(login: '677JADED7283') + end + + def self.shipment_array(ship_params) + #from params, goes through packages and gets each one in the format needed to make the call to active shipping + #the shipment array is an array of hashes. each hash has a key and the value is an object that can be used by active shipping to make the call to the shipping service + #notice that because there are multiple package items, each package needs to be packed by calling on the pack_items method + shipment_array = [] + ship_params[:packages].each do |package| + package_info = pack_items(package) + active_origin = ActiveShipping::Location.new(package[:origin]) + active_destination = destination(ship_params) + active_package = ActiveShipping::Package.new(package_info[:weight], package_info[:dimensions], :units => :imperial, :value => 10) #had to add value for delivery date estimate + + package_hash = {origin: active_origin, + destination: active_destination, + package: active_package + } + shipment_array << package_hash + end + return shipment_array + end + + def self.pack_items(package) + #different items from the same merchant will be packed together into one package + #a simple way to do this is to find the longest length and and add together the widths and heights. return the total weight and the dimensions of this package. + #this method assumes the length is the longest dimension + weight = package[:package_items].map {|item| item[:weight].to_i}.sum + longest_package = package[:package_items].max_by{|item| item[:length]} + length = longest_package[:length].to_i + width = package[:package_items].map {|item| item[:width].to_i}.sum + height = package[:package_items].map {|item| item[:height].to_i}.sum + package_info = {weight: weight, dimensions: [length, width, height]} + return package_info + end + + def self.destination(ship_params) + # keeping destination as a separate method since that's how params will come in from wetsy. + #would have been better maybe to keep it more general for our api, so that packages can have any destination. But that's ok. + ActiveShipping::Location.new(ship_params[:destination]) + end + + def self.get_rates(shipment_array, carrier) + #rates is an array of hashes with different shipping services and their cost. + #total_carrier_rates creates as array summing the cost of each of the packages for the same service + #this is where are are taking all the packages from each different merchant and summing their shipping costs + #error handling takes care of things when the zip code is invalid, etc + carrier_rates = [] + shipment_array.each do |shipment| + response = carrier.find_rates(shipment[:origin], shipment[:destination], shipment[:package]) + sorted_rates = (response.rates.sort_by(&:price).collect {|rate| [rate.service_name, rate.price]}).to_h + carrier_rates << sorted_rates + end + rescue ActiveShipping::ResponseError + @api_call_ok = false + else + total_carrier_rates = Hash.new(0) + carrier_rates.each { |subhash| subhash.each { |service, cost| total_carrier_rates[service] += cost } } + return total_carrier_rates + end + + def self.assemble_quote(ups, usps) + {"UPS" => ups, "USPS" => usps} + end + end +end diff --git a/spec/controllers/estimates_controller_spec.rb b/spec/controllers/estimates_controller_spec.rb new file mode 100644 index 0000000..0ea3f1c --- /dev/null +++ b/spec/controllers/estimates_controller_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' +require 'pry' +require 'curl' +require 'support/params_hash.rb' + +RSpec.describe EstimatesController, type: :controller do + describe "POST 'quote'" do + include_context "using shipping params" + + context "successful api call" do + it "is successful" do + get :quote, params, { format: :json } + expect(response.response_code).to eq 200 + end + + it "returns json" do + get :quote, params, { format: :json } + expect(response.header['Content-Type']).to include 'application/json' + end + + it "returned json that is formatted properly" do + get :quote, params, { format: :json } + parsed_body = JSON.parse(response.body) + expect(parsed_body["UPS"]).to_not be_nil + expect(parsed_body["UPS"]["UPS Ground"]).to_not be_nil + expect(parsed_body["USPS"]).to_not be_nil + end + end + + context "failed api call" do + it "returns a 204 (no content)" do + get :quote, bad_params, { format: :json } + expect(response.response_code).to eq 204 + end + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index e69de29..8b13789 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -0,0 +1 @@ + diff --git a/spec/helpers/estimates_helper_spec.rb b/spec/helpers/estimates_helper_spec.rb new file mode 100644 index 0000000..f0f463a --- /dev/null +++ b/spec/helpers/estimates_helper_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the EstimatesHelper. For example: +# +# describe EstimatesHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe EstimatesHelper, type: :helper do +end diff --git a/spec/lib/Estimator_spec.rb b/spec/lib/Estimator_spec.rb new file mode 100644 index 0000000..3f045b0 --- /dev/null +++ b/spec/lib/Estimator_spec.rb @@ -0,0 +1,6 @@ +require 'rails_helper' +require 'pry' + +RSpec.describe Estimator, type: :controller do + +end diff --git a/spec/support/params_hash.rb b/spec/support/params_hash.rb new file mode 100644 index 0000000..0177c8a --- /dev/null +++ b/spec/support/params_hash.rb @@ -0,0 +1,142 @@ +RSpec.shared_context "using shipping params" do + + let!(:params) do + { :shipping_params => + {:destination => + { :country => "US", + :state => "WA", + :city => "Seattle", + :postal_code => "98122"}, + :packages => + [{ :origin => + { :country => "US", + :state => "MA", + :city => "Hinsdale", + :postal_code => "01235"}, + :package_items => + [{ :weight => 12, + :height => 9, + :length => 10, + :width => 9 }, + { :weight => 3.453, + :height => 1, + :length => 10, + :width => 9 }, + { :weight => 30.45, + :height => 3, + :length => 10, + :width => 5 }]}, + { :origin => + { :country => "US", + :state => "CT", + :city => "New London", + :postal_code => "06320"}, + :package_items => + [{:weight => 9.45, + :height => 2, + :length => 10, + :width => 9 }]}, + { :origin => + { :country => "US", + :state => "CA", + :city => "San Fransisco", + :postal_code => "94105"}, + :package_items => + [{:weight => 50, + :height => 4, + :length => 14, + :width => 10 }]}, + { :origin => + { :country => "US", + :state => "IL", + :city => "Chicago", + :postal_code => "60604"}, + :package_items => + [{:weight => 1000, + :height => 4, + :length => 30, + :width => 10 }]}, + { :origin => + { :country => "US", + :state => "AK", + :city => "Juneau", + :postal_code => "99802"}, + :package_items => + [{:weight => 80, + :height => 4, + :length => 50, + :width => 10 }]}] + } + } + end + + let!(:bad_params) do + { :shipping_params => + {:destination => + { :country => "US", + :state => "WA", + :city => "Seattle", + :postal_code => "00000"}, + :packages => + [{ :origin => + { :country => "US", + :state => "MA", + :city => "Hinsdale", + :postal_code => "01235"}, + :package_items => + [{ :weight => 12, + :height => 15, + :length => 10, + :width => 12 }, + { :weight => 3.453, + :height => 11, + :length => 1, + :width => 14 }, + { :weight => 30.45, + :height => 3, + :length => 40, + :width => 5 }]}, + { :origin => + { :country => "US", + :state => "CT", + :city => "New London", + :postal_code => "06320"}, + :package_items => + [{:weight => 9.45, + :height => 2, + :length => 17, + :width => 14 }]}, + { :origin => + { :country => "US", + :state => "CA", + :city => "San Fransisco", + :postal_code => "94105"}, + :package_items => + [{:weight => 500, + :height => 4, + :length => 104, + :width => 10 }]}, + { :origin => + { :country => "US", + :state => "IL", + :city => "Chicago", + :postal_code => "60604"}, + :package_items => + [{:weight => 1000, + :height => 4, + :length => 90, + :width => 10 }]}, + { :origin => + { :country => "US", + :state => "AK", + :city => "Juneau", + :postal_code => "99802"}, + :package_items => + [{:weight => 80, + :height => 4, + :length => 80, + :width => 10 }]}] + } + } + end +end