From 6808e307b37e7c28c0f793d12622bb802719e126 Mon Sep 17 00:00:00 2001 From: Alex Vondrak Date: Tue, 11 Nov 2014 13:29:26 -0800 Subject: [PATCH 1/2] Remove .format suffixes from paths if you narrow your API to only one format. --- lib/grape/path.rb | 8 +++++++- spec/grape/api_spec.rb | 10 ++++++---- spec/grape/path_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/grape/path.rb b/lib/grape/path.rb index c0eac123c9..b25f05a02c 100644 --- a/lib/grape/path.rb +++ b/lib/grape/path.rb @@ -20,6 +20,10 @@ def root_prefix split_setting(:root_prefix, '/') end + def uses_specific_format? + !!(settings[:format] && settings[:content_types].size == 1) + end + def uses_path_versioning? !!(settings[:version] && settings[:version_options][:using] == :path) end @@ -33,7 +37,9 @@ def has_path? end def suffix - if !uses_path_versioning? || (has_namespace? || has_path?) + if uses_specific_format? + '' + elsif !uses_path_versioning? || (has_namespace? || has_path?) '(.:format)' else '(/.:format)' diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 25c937747a..21fe6da9a3 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -257,6 +257,8 @@ def app describe 'root routes should work with' do before do subject.format :txt + subject.content_type :json, "application/json" + subject.formatter :json, lambda { |object, env| object } def subject.enable_root_route! get("/") { "root" } end @@ -784,14 +786,14 @@ def subject.enable_root_route! it 'sets content type for json error' do subject.format :json subject.get('/error') { error!('error in json', 500) } - get '/error.json' + get '/error' expect(last_response.headers['Content-Type']).to eql 'application/json' end it 'sets content type for xml error' do subject.format :xml subject.get('/error') { error!('error in xml', 500) } - get '/error.xml' + get '/error' expect(last_response.headers['Content-Type']).to eql 'application/xml' end @@ -2420,9 +2422,9 @@ def static get '/meaning_of_life' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end - it 'forces txt with the wrong extension' do + it 'does not accept any extensions' do get '/meaning_of_life.json' - expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) + expect(last_response.status).to eq(404) end it 'forces txt from a non-accepting header' do get '/meaning_of_life', {}, 'HTTP_ACCEPT' => 'application/json' diff --git a/spec/grape/path_spec.rb b/spec/grape/path_spec.rb index b81f67697d..900dee1b45 100644 --- a/spec/grape/path_spec.rb +++ b/spec/grape/path_spec.rb @@ -182,9 +182,19 @@ module Grape end describe "#suffix" do + context "when using a specific format" do + it "is empty" do + path = Path.new(nil, nil, {}) + allow(path).to receive(:uses_specific_format?) { true } + + expect(path.suffix).to eql('') + end + end + context "when path versioning is used" do it "includes a '/'" do path = Path.new(nil, nil, {}) + allow(path).to receive(:uses_specific_format?) { false } allow(path).to receive(:uses_path_versioning?) { true } expect(path.suffix).to eql('(/.:format)') @@ -194,6 +204,7 @@ module Grape context "when path versioning is not used" do it "does not include a '/' when the path has a namespace" do path = Path.new(nil, 'namespace', {}) + allow(path).to receive(:uses_specific_format?) { false } allow(path).to receive(:uses_path_versioning?) { true } expect(path.suffix).to eql('(.:format)') @@ -201,6 +212,7 @@ module Grape it "does not include a '/' when the path has a path" do path = Path.new('/path', nil, {}) + allow(path).to receive(:uses_specific_format?) { false } allow(path).to receive(:uses_path_versioning?) { true } expect(path.suffix).to eql('(.:format)') @@ -208,6 +220,7 @@ module Grape it "includes a '/' otherwise" do path = Path.new(nil, nil, {}) + allow(path).to receive(:uses_specific_format?) { false } allow(path).to receive(:uses_path_versioning?) { true } expect(path.suffix).to eql('(/.:format)') @@ -223,6 +236,16 @@ module Grape expect(path.path_with_suffix).to eql('/the/pathsuffix') end + + context "when using a specific format" do + it "does not have a suffix" do + path = Path.new(nil, nil, {}) + allow(path).to receive(:path) { '/the/path' } + allow(path).to receive(:uses_specific_format?) { true } + + expect(path.path_with_suffix).to eql('/the/path') + end + end end end From 9f05356576b06b2d3741ab1863b3e670475309a6 Mon Sep 17 00:00:00 2001 From: Alex Vondrak Date: Wed, 12 Nov 2014 12:40:50 -0800 Subject: [PATCH 2/2] #809: update docs & changelog --- CHANGELOG.md | 1 + README.md | 162 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 115 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6c5bda94..463064b74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * [#779](https://github.com/intridea/grape/pull/779): Fixed using `values` with a `default` proc - [@ShPakvel](https://github.com/ShPakvel). * [#799](https://github.com/intridea/grape/pull/799): Fixed custom validators with required `Hash`, `Array` types - [@bwalex](https://github.com/bwalex). * [#784](https://github.com/intridea/grape/pull/784): Fixed `present` to not overwrite the previously added contents of the response body whebn called more than once - [@mfunaro](https://github.com/mfunaro). +* [#809](https://github.com/intridea/grape/pull/809): Removed automatic `(.:format)` suffix on paths if you're using only one format (e.g., with `format :json`, `/path` will respond with JSON but `/path.xml` will be a 404) - [@ajvondrak](https://github.com/ajvondrak). * Your contribution here. 0.9.0 (8/27/2014) diff --git a/README.md b/README.md index 2afbff91b3..3fe03af027 100644 --- a/README.md +++ b/README.md @@ -196,12 +196,12 @@ run Twitter::API And would respond to the following routes: - GET /api/statuses/public_timeline(.json) - GET /api/statuses/home_timeline(.json) - GET /api/statuses/:id(.json) - POST /api/statuses(.json) - PUT /api/statuses/:id(.json) - DELETE /api/statuses/:id(.json) + GET /api/statuses/public_timeline + GET /api/statuses/home_timeline + GET /api/statuses/:id + POST /api/statuses + PUT /api/statuses/:id + DELETE /api/statuses/:id Grape will also automatically respond to HEAD and OPTIONS for all GET, and just OPTIONS for all other routes. @@ -1332,16 +1332,115 @@ end ## API Formats -By default, Grape supports _XML_, _JSON_, _BINARY_, and _TXT_ content-types. The default format is `:txt`. +Your API can declare which content-types to support by using `content_type`. If you do not specify any, Grape will support +_XML_, _JSON_, _BINARY_, and _TXT_ content-types. The default format is `:txt`; you can change this with `default_format`. +Essentially, the two APIs below are equivalent. -Serialization takes place automatically. For example, you do not have to call `to_json` in each JSON API implementation. +```ruby +class Twitter::API < Grape::API + # no content_type declarations, so Grape uses the defaults +end + +class Twitter::API < Grape::API + # the following declarations are equivalent to the defaults + + content_type :xml, 'application/xml' + content_type :json, 'application/json' + content_type :binary, 'application/octet-stream' + content_type :txt, 'text/plain' + + default_format :txt +end +``` + +If you declare any `content_type` whatsoever, the Grape defaults will be overridden. For example, the following API will only +support the `:xml` and `:rss` content-types, but not `:txt`, `:json`, or `:binary`. Importantly, this means the `:txt` +default format is not supported! So, make sure to set a new `default_format`. + +```ruby +class Twitter::API < Grape::API + content_type :xml, 'application/xml' + content_type :rss, 'application/xml+rss' + + default_format :xml +end +``` + +Serialization takes place automatically. For example, you do not have to call `to_json` in each JSON API endpoint +implementation. The response format (and thus the automatic serialization) is determined in the following order: +* Use the file extension, if specified. If the file is .json, choose the JSON format. +* Use the value of the `format` parameter in the query string, if specified. +* Use the format set by the `format` option, if specified. +* Attempt to find an acceptable format from the `Accept` header. +* Use the default format, if specified by the `default_format` option. +* Default to `:txt`. + +For example, consider the following API. + +```ruby +class MultipleFormatAPI < Grape::API + content_type :xml, 'application/xml' + content_type :json, 'application/json' + + default_format :json + + get :hello do + { hello: 'world' } + end +end +``` + +* `GET /hello` (with an `Accept: */*` header) does not have an extension or a `format` parameter, so it will respond with + JSON (the default format). +* `GET /hello.xml` has a recognized extension, so it will respond with XML. +* `GET /hello?format=xml` has a recognized `format` parameter, so it will respond with XML. +* `GET /hello.xml?format=json` has a recognized extension (which takes precedence over the `format` parameter), so it will + respond with XML. +* `GET /hello.xls` (with an `Accept: */*` header) has an extension, but that extension is not recognized, so it will respond + with JSON (the default format). +* `GET /hello.xls` with an `Accept: application/xml` header has an unrecognized extension, but the `Accept` header + corresponds to a recognized format, so it will respond with XML. +* `GET /hello.xls` with an `Accept: text/plain` header has an unrecognized extension *and* an unrecognized `Accept` header, + so it will respond with JSON (the default format). + +You can override this process explicitly by specifying `env['api.format']` in the API itself. +For example, the following API will let you upload arbitrary files and return their contents as an attachment with the correct MIME type. + +```ruby +class Twitter::API < Grape::API + post "attachment" do + filename = params[:file][:filename] + content_type MIME::Types.type_for(filename)[0].to_s + env['api.format'] = :binary # there's no formatter for :binary, data will be returned "as is" + header "Content-Disposition", "attachment; filename*=UTF-8''#{URI.escape(filename)}" + params[:file][:tempfile].read + end +end +``` + +You can have your API only respond to a single format with `format`. If you use this, the API will **not** respond to file +extensions. For example, consider the following API. + +```ruby +class SingleFormatAPI < Grape::API + format :json + + get :hello do + { hello: 'world' } + end +end +``` -Your API can declare which types to support by using `content_type`. Response format is determined by the -request's extension, an explicit `format` parameter in the query string, or `Accept` header. +* `GET /hello` will respond with JSON. +* `GET /hello.xml`, `GET /hello.json`, `GET /hello.foobar`, or *any* other extension will respond with an HTTP 404 error code. +* `GET /hello?format=xml` will respond with an HTTP 406 error code, because the XML format specified by the request parameter + is not supported. +* `GET /hello` with an `Accept: application/xml` header will still respond with JSON, since it could not negotiate a + recognized content-type from the headers and JSON is the effective default. -The following API will only respond to the JSON content-type and will not parse any other input than `application/json`, -`application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related` and `multipart/mixed`. All other requests -will fail with an HTTP 406 error code. +The formats apply to parsing, too. The following API will only respond to the JSON content-type and will not parse any other +input than `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related` and +`multipart/mixed`. All other requests will fail with an HTTP 406 error code. ```ruby class Twitter::API < Grape::API @@ -1394,46 +1493,13 @@ class Twitter::API < Grape::API end ``` -Built-in formats are the following. +Built-in formatters are the following. * `:json`: use object's `to_json` when available, otherwise call `MultiJson.dump` * `:xml`: use object's `to_xml` when available, usually via `MultiXml`, otherwise call `to_s` * `:txt`: use object's `to_txt` when available, otherwise `to_s` * `:serializable_hash`: use object's `serializable_hash` when available, otherwise fallback to `:json` -* `:binary` - -Use `default_format` to set the fallback format when the format could not be determined from the `Accept` header. -See below for the order for choosing the API format. - -```ruby -class Twitter::API < Grape::API - default_format :json -end -``` - -The order for choosing the format is the following. - -* Use the file extension, if specified. If the file is .json, choose the JSON format. -* Use the value of the `format` parameter in the query string, if specified. -* Use the format set by the `format` option, if specified. -* Attempt to find an acceptable format from the `Accept` header. -* Use the default format, if specified by the `default_format` option. -* Default to `:txt`. - -You can override this process explicitly by specifying `env['api.format']` in the API itself. -For example, the following API will let you upload arbitrary files and return their contents as an attachment with the correct MIME type. - -```ruby -class Twitter::API < Grape::API - post "attachment" do - filename = params[:file][:filename] - content_type MIME::Types.type_for(filename)[0].to_s - env['api.format'] = :binary # there's no formatter for :binary, data will be returned "as is" - header "Content-Disposition", "attachment; filename*=UTF-8''#{URI.escape(filename)}" - params[:file][:tempfile].read - end -end -``` +* `:binary`: data will be returned "as is" ### JSONP