diff --git a/app/assets/javascripts/admin/models/api/settings.js b/app/assets/javascripts/admin/models/api/settings.js index d54a64ae..6902b99c 100644 --- a/app/assets/javascripts/admin/models/api/settings.js +++ b/app/assets/javascripts/admin/models/api/settings.js @@ -13,6 +13,8 @@ Admin.ApiSettings = Ember.Model.extend({ authenticatedRateLimitBehavior: Ember.attr(), passApiKeyHeader: Ember.attr(), passApiKeyQueryParam: Ember.attr(), + defaultResponseHeadersString: Ember.attr(), + overrideResponseHeadersString: Ember.attr(), errorTemplates: Ember.attr(), errorDataYamlStrings: Ember.attr(), diff --git a/app/assets/javascripts/admin/templates/apis/_form.hbs b/app/assets/javascripts/admin/templates/apis/_form.hbs index 49b83c1f..eb743e94 100644 --- a/app/assets/javascripts/admin/templates/apis/_form.hbs +++ b/app/assets/javascripts/admin/templates/apis/_form.hbs @@ -80,7 +80,7 @@ inputConfig='class:span12'}} {{input headersString as='text' class='row-fluid' - label='Set Headers' + label='Set Request Headers' placeholder='X-Example-Header: value' inputConfig='class:span12'}} {{input httpBasicAuth diff --git a/app/assets/javascripts/admin/templates/apis/settings_fields.hbs b/app/assets/javascripts/admin/templates/apis/settings_fields.hbs index 840fd6e0..d1534d3b 100644 --- a/app/assets/javascripts/admin/templates/apis/settings_fields.hbs +++ b/app/assets/javascripts/admin/templates/apis/settings_fields.hbs @@ -19,7 +19,7 @@
- {{tooltip-field title="

Whether to pass the user's api key to this API backend.

Deprecated: This is deprecated and support of this will be removed in the future. Enabling either option is not recommend.

If your API backend needs to uniquely reference the requesting user, use the X-Api-User-Id HTTP header instead.

Note: Passing via GET query parameter with render API Umbrella's HTTP caching layer mostly ineffectual (since the cache will be mainted per api key)."}} + {{tooltip-field title="

Whether to pass the user's api key to this API backend.

Deprecated: This is deprecated and support of this will be removed in the future. Enabling either option is not recommend.

If your API backend needs to uniquely reference the requesting user, use the X-Api-User-Id HTTP header instead.

Note: Passing via GET query parameter with render API Umbrella's HTTP caching layer mostly ineffectual (since the cache will be maintained per api key)."}}

{{render 'apis/settings_rate_limit_fields' this}} + +{{input defaultResponseHeadersString as='text' + class='row-fluid' + label='Default Response Headers' + placeholder='X-Example-Header: value' + tooltip='

Define header values that will be set in the response regardless of whether the header is already set in the response.

+

For example, to set default CORS headers on all responses that don\'t explicitly set their own CORS headers:

+
Access-Control-Allow-Origin: *
' + inputConfig='class:span12'}} +{{input overrideResponseHeadersString as='text' + class='row-fluid' + label='Override Response Headers' + placeholder='X-Example-Header: value' + tooltip='

Define header values that will be set in the response regardless of whether the header is already set in the response.

+

For example, to force CORS headers on all responses:

+
Access-Control-Allow-Origin: *
' + inputConfig='class:span12'}} diff --git a/app/models/api.rb b/app/models/api.rb index 8424c4bb..fb1a134e 100644 --- a/app/models/api.rb +++ b/app/models/api.rb @@ -85,7 +85,7 @@ def attributes_hash def as_json(options) options[:methods] ||= [] - options[:methods] += [:error_data_yaml_strings, :headers_string] + options[:methods] += [:error_data_yaml_strings, :headers_string, :default_response_headers_string, :override_response_headers_string] json = super(options) diff --git a/app/models/api/settings.rb b/app/models/api/settings.rb index fbc5625b..8ae3c2a9 100644 --- a/app/models/api/settings.rb +++ b/app/models/api/settings.rb @@ -21,6 +21,8 @@ class Api::Settings # Relations embeds_many :headers, :class_name => "Api::Header" embeds_many :rate_limits, :class_name => "Api::RateLimit" + embeds_many :default_response_headers, :class_name => "Api::Header" + embeds_many :override_response_headers, :class_name => "Api::Header" embedded_in :api embedded_in :sub_settings embedded_in :api_user @@ -35,7 +37,7 @@ class Api::Settings validate :validate_error_data_yaml_strings # Nested attributes - accepts_nested_attributes_for :headers, :rate_limits, :allow_destroy => true + accepts_nested_attributes_for :headers, :rate_limits, :default_response_headers, :override_response_headers, :allow_destroy => true # Mass assignment security attr_accessible :append_query_string, @@ -52,40 +54,37 @@ class Api::Settings :allowed_referers, :error_templates, :error_data_yaml_strings, + :headers, :headers_string, :rate_limits_attributes, + :default_response_headers, + :default_response_headers_string, + :override_response_headers, + :override_response_headers_string, :as => [:default, :admin] def headers_string - unless @headers_string - @headers_string = "" - if(self.headers.present?) - @headers_string = self.headers.map do |header| - header.to_s - end.join("\n") - end - end - - @headers_string + read_headers_string(:headers) end def headers_string=(string) - @headers_string = string + write_headers_string(:headers, string) + end - header_objects = [] + def default_response_headers_string + read_headers_string(:default_response_headers) + end - header_lines = string.split(/[\r\n]+/) - header_lines.each do |line| - next if(line.strip.blank?) + def default_response_headers_string=(string) + write_headers_string(:default_response_headers, string) + end - parts = line.split(":", 2) - header_objects << Api::Header.new({ - :key => parts[0].to_s.strip, - :value => parts[1].to_s.strip, - }) - end + def override_response_headers_string + read_headers_string(:override_response_headers) + end - self.headers = header_objects + def override_response_headers_string=(string) + write_headers_string(:override_response_headers, string) end def error_templates=(templates) @@ -130,6 +129,43 @@ def error_data_yaml_strings=(strings) private + def read_headers_string(field) + @headers_strings ||= {} + field = field.to_sym + + unless @headers_strings[field] + @headers_strings[field] = "" + current_value = self.send(field) + if(current_value.present?) + @headers_strings[field] = current_value.map do |header| + header.to_s + end.join("\n") + end + end + + @headers_strings[field] + end + + def write_headers_string(field, string) + header_objects = [] + + if(string.present?) + header_lines = string.split(/[\r\n]+/) + header_lines.each do |line| + next if(line.strip.blank?) + + parts = line.split(":", 2) + header_objects << Api::Header.new({ + :key => parts[0].to_s.strip, + :value => parts[1].to_s.strip, + }) + end + end + + self.send(:"#{field}=", header_objects) + @headers_strings.delete(field.to_sym) if(@headers_strings) + end + def validate_error_data_yaml_strings strings = @error_data_yaml_strings if(strings.present?) diff --git a/lib/api_umbrella/attributify_data.rb b/lib/api_umbrella/attributify_data.rb index 5539caed..1ddcdbe1 100644 --- a/lib/api_umbrella/attributify_data.rb +++ b/lib/api_umbrella/attributify_data.rb @@ -49,7 +49,7 @@ def attributify_settings!(data, old_data) settings_data = data["settings_attributes"] old_settings_data = old_data["settings"] if(old_data.present?) - %w(headers rate_limits).each do |collection_name| + %w(headers rate_limits default_response_headers override_response_headers).each do |collection_name| attributify_embeds_many!(settings_data, collection_name, old_settings_data) end end diff --git a/spec/controllers/api/v1/apis_controller_spec.rb b/spec/controllers/api/v1/apis_controller_spec.rb index 52978886..9b5e25b1 100644 --- a/spec/controllers/api/v1/apis_controller_spec.rb +++ b/spec/controllers/api/v1/apis_controller_spec.rb @@ -18,6 +18,32 @@ :required_roles => [ "test-write", ], + :headers => [ + { + :key => "X-Add1", + :value => "test1", + }, + { + :key => "X-Add2", + :value => "test2", + }, + ], + :default_response_headers => [ + { + :key => "X-Default-Add1", + :value => "test1", + }, + { + :key => "X-Default-Add2", + :value => "test2", + }, + ], + :override_response_headers => [ + { + :key => "X-Override-Add1", + :value => "test1", + }, + ], }), }) @google_api = FactoryGirl.create(:google_api) @@ -38,6 +64,247 @@ @empty_url_prefixes_api.save!(:validate => false) end + shared_examples "api settings header fields - show" do |field| + it "returns no headers as an empty string" do + api = FactoryGirl.create(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + }), + }) + + admin_token_auth(@admin) + get :show, :format => "json", :id => api.id + + response.status.should eql(200) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("") + end + + it "returns a single header as a string" do + api = FactoryGirl.create(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}" => [ + FactoryGirl.attributes_for(:api_header, { :key => "X-Add1", :value => "test1" }), + ], + }), + }) + + admin_token_auth(@admin) + get :show, :format => "json", :id => api.id + + response.status.should eql(200) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("X-Add1: test1") + end + + it "returns multiple headers as a new-line separated string" do + api = FactoryGirl.create(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}" => [ + FactoryGirl.attributes_for(:api_header, { :key => "X-Add1", :value => "test1" }), + FactoryGirl.attributes_for(:api_header, { :key => "X-Add2", :value => "test2" }), + ], + }), + }) + + admin_token_auth(@admin) + get :show, :format => "json", :id => api.id + + response.status.should eql(200) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("X-Add1: test1\nX-Add2: test2") + end + end + + shared_examples "api settings header fields - create" do |field| + it "accepts a nil value" do + admin_token_auth(@admin) + attributes = FactoryGirl.attributes_for(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}_string" => nil, + }), + }) + + expect do + post :create, :format => "json", :api => attributes + response.status.should eql(201) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("") + + api = Api.find(data["api"]["id"]) + api.settings.send(field).length.should eql(0) + end.to change { Api.count }.by(1) + end + + it "accepts an empty string" do + admin_token_auth(@admin) + attributes = FactoryGirl.attributes_for(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}_string" => "", + }), + }) + + expect do + post :create, :format => "json", :api => attributes + response.status.should eql(201) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("") + + api = Api.find(data["api"]["id"]) + api.settings.send(field).length.should eql(0) + end.to change { Api.count }.by(1) + end + + it "parses a single header from a string" do + admin_token_auth(@admin) + attributes = FactoryGirl.attributes_for(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}_string" => "X-Add1: test1", + }), + }) + + expect do + post :create, :format => "json", :api => attributes + response.status.should eql(201) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("X-Add1: test1") + + api = Api.find(data["api"]["id"]) + api.settings.send(field).length.should eql(1) + end.to change { Api.count }.by(1) + end + + it "parses a multiple headers from a string" do + admin_token_auth(@admin) + attributes = FactoryGirl.attributes_for(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}_string" => "X-Add1: test1\nX-Add2: test2", + }), + }) + + expect do + post :create, :format => "json", :api => attributes + response.status.should eql(201) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("X-Add1: test1\nX-Add2: test2") + + api = Api.find(data["api"]["id"]) + api.settings.send(field).length.should eql(2) + end.to change { Api.count }.by(1) + end + + it "strips extra whitespace from the string" do + admin_token_auth(@admin) + attributes = FactoryGirl.attributes_for(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}_string" => "\n\n X-Add1:test1\n\n\nX-Add2: test2 \n\n", + }), + }) + + expect do + post :create, :format => "json", :api => attributes + response.status.should eql(201) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("X-Add1: test1\nX-Add2: test2") + + api = Api.find(data["api"]["id"]) + api.settings.send(field).length.should eql(2) + end.to change { Api.count }.by(1) + end + + it "only parses to the first colon for the header name" do + admin_token_auth(@admin) + attributes = FactoryGirl.attributes_for(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}_string" => "X-Add1: test1:test2", + }), + }) + + expect do + post :create, :format => "json", :api => attributes + response.status.should eql(201) + data = MultiJson.load(response.body) + data["api"]["settings"]["#{field}_string"].should eql("X-Add1: test1:test2") + + api = Api.find(data["api"]["id"]) + api.settings.send(field).length.should eql(1) + end.to change { Api.count }.by(1) + end + + end + + shared_examples "api settings header fields - update" do |field| + it "clears existing headers when passed nil" do + admin_token_auth(@admin) + + api = FactoryGirl.create(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}" => [ + FactoryGirl.attributes_for(:api_header, { :key => "X-Add1", :value => "test1" }), + ], + }), + }) + + api.settings.send(field).length.should eql(1) + + attributes = api.serializable_hash + attributes["settings"].delete(field.to_s) + attributes["settings"]["#{field}_string"] = nil + put :update, :format => "json", :id => api.id, :api => attributes + + response.status.should eql(204) + api = Api.find(api.id) + api.settings.send(field).length.should eql(0) + end + + it "clears existing headers when passed an empty string" do + admin_token_auth(@admin) + + api = FactoryGirl.create(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}" => [ + FactoryGirl.attributes_for(:api_header, { :key => "X-Add1", :value => "test1" }), + ], + }), + }) + + api.settings.send(field).length.should eql(1) + + attributes = api.serializable_hash + attributes["settings"].delete(field.to_s) + attributes["settings"]["#{field}_string"] = "" + put :update, :format => "json", :id => api.id, :api => attributes + + response.status.should eql(204) + api = Api.find(api.id) + api.settings.send(field).length.should eql(0) + end + + it "replaces existing headers when passed the headers string" do + admin_token_auth(@admin) + + api = FactoryGirl.create(:api, { + :settings => FactoryGirl.attributes_for(:api_setting, { + :"#{field}" => [ + FactoryGirl.attributes_for(:api_header, { :key => "X-Add1", :value => "test1" }), + ], + }), + }) + + api.settings.send(field).length.should eql(1) + api.settings.send(field).map { |h| h.key }.should eql(["X-Add1"]) + + attributes = api.serializable_hash + attributes["settings"].delete(field.to_s) + attributes["settings"]["#{field}_string"] = "X-New1: test1\nX-New2: test2" + put :update, :format => "json", :id => api.id, :api => attributes + + response.status.should eql(204) + api = Api.find(api.id) + api.settings.send(field).length.should eql(2) + api.settings.send(field).map { |h| h.key }.should eql(["X-New1", "X-New2"]) + end + end + describe "GET index" do it "returns datatables output fields" do admin_token_auth(@admin) @@ -187,6 +454,18 @@ data.keys.should eql(["errors"]) end end + + describe "request headers" do + it_behaves_like "api settings header fields - show", :headers + end + + describe "response default headers" do + it_behaves_like "api settings header fields - show", :default_response_headers + end + + describe "response override headers" do + it_behaves_like "api settings header fields - show", :override_response_headers + end end describe "POST create" do @@ -465,6 +744,18 @@ end.to change { Api.count }.by(1) end end + + describe "request headers" do + it_behaves_like "api settings header fields - create", :headers + end + + describe "response default headers" do + it_behaves_like "api settings header fields - create", :default_response_headers + end + + describe "response override headers" do + it_behaves_like "api settings header fields - create", :override_response_headers + end end describe "PUT update" do @@ -730,6 +1021,18 @@ @google_api.settings.required_roles.should eql(attributes[:settings][:required_roles]) end end + + describe "request headers" do + it_behaves_like "api settings header fields - update", :headers + end + + describe "response default headers" do + it_behaves_like "api settings header fields - update", :default_response_headers + end + + describe "response override headers" do + it_behaves_like "api settings header fields - update", :override_response_headers + end end describe "DELETE destroy" do