Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Don't use .format suffix in paths if you only have one format #809

Merged
merged 2 commits into from
Nov 13, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
162 changes: 114 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion lib/grape/path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)'
Expand Down
10 changes: 6 additions & 4 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand Down
23 changes: 23 additions & 0 deletions spec/grape/path_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)')
Expand All @@ -194,20 +204,23 @@ 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)')
end

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)')
end

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)')
Expand All @@ -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
Expand Down