Skip to content
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 @@
* [#2960](https://github.com/ruby-grape/grape/pull/2960): Lint every response of an API once under `lint!`, including the router's 404 and mounted Rack apps, instead of inside each endpoint's stack - [@ericproulx](https://github.com/ericproulx).
* [#2966](https://github.com/ruby-grape/grape/pull/2966): Require Ruby 3.3.1, the first 3.3 release that parses anonymous argument forwarding from inside a block - [@ericproulx](https://github.com/ericproulx).
* [#3008](https://github.com/ruby-grape/grape/pull/3008): Add the grape-on-rack app as a live-server memory profiling harness - [@ericproulx](https://github.com/ericproulx).
* [#3025](https://github.com/ruby-grape/grape/pull/3025): Add `:md` and `:markdown` formatters that render an object's `to_markdown`, for APIs that declare a `text/markdown` content type - [@ericproulx](https://github.com/ericproulx).
* [#3029](https://github.com/ruby-grape/grape/pull/3029): Add a built-in `:jsonl` (JSON Lines) format that streams an Enumerator body - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3351,6 +3351,30 @@ Built-in formatters are the following.
* `:serializable_hash`: use object's `serializable_hash` when available, otherwise fallback to `:json`
* `:binary`: data will be returned "as is"
* `:jsonl`: one line per element of an Array or Enumerator, otherwise one line, each rendered as `:json` renders it. Not a default content type, see [JSON Lines](#json-lines)
* `:md`, `:markdown`: use object's `to_markdown` when available, return a String "as is", otherwise raise `Grape::Exceptions::InvalidFormatter`. Not default content types, see [Serving Markdown](#serving-markdown)

#### Serving Markdown

`:md` and `:markdown` are not among the default content types. Declare either one to answer clients that ask for Markdown, such as LLM agents sending `Accept: text/markdown`.

```ruby
class API < Grape::API
content_type :json, 'application/json'
content_type :md, 'text/markdown; charset=utf-8'
default_format :json

get '/pages/:id' do
Page.find(params[:id]) # renders with Page#to_markdown
end
end
```

* `GET /pages/1.md`, `GET /pages/1.markdown`, `GET /pages/1?format=md`, `?format=markdown` and `GET /pages/1` with an `Accept: text/markdown` header answer `text/markdown`.
* `:md` and `:markdown` name the same format: declaring either one answers both extensions, under the name declared. A request for `.markdown` on the API above negotiates `:md`, so a `formatter :md` of your own renders it too.
* `to_markdown` is the method Rails' `render markdown:` calls (Rails 8.1), so an object written for a Rails controller renders the same way in Grape.
* Grape does not convert anything else to Markdown. When the client negotiated Markdown, a body without `to_markdown` that is not a String answers in the default format instead; when the API pins `format :md`, it answers `500`.
* [RFC 7763](https://www.rfc-editor.org/rfc/rfc7763) makes `charset` a required parameter of `text/markdown`, hence `charset=utf-8`.
* Errors are rendered as plain text, which is valid Markdown.

#### Serving pre-rendered JSON

Expand Down
9 changes: 9 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ Grape now renders, parses and reports errors in JSON Lines under `:jsonl` ([#302

A formatter, parser or error formatter you register for `:jsonl` with `formatter`, `parser` or `error_formatter` still takes precedence.

#### Formats named `:md` and `:markdown` have a built-in formatter

`Grape::Formatter::Md` renders the `:md` format and `Grape::Formatter::Markdown` the `:markdown` one ([#3025](https://github.com/ruby-grape/grape/pull/3025)). Neither is a default content type, so only an API that declares one is affected. See [Serving Markdown](README.md#serving-markdown).

* An API that declared `content_type :md` (or `:markdown`) without a matching `formatter` used to send its bodies unchanged. A String still is. An object answering `to_markdown` is now rendered with it, and any other object raises `Grape::Exceptions::InvalidFormatter`: it answers in the default format when the client negotiated Markdown, and `500` otherwise.
* `:md` and `:markdown` now name the same format. An API that declared one of them used to answer the other extension (and `?format=`) in its default format, or `406`; it now answers it in Markdown, under the name it declared.
* A `formatter :md` or `formatter :markdown` of your own still takes precedence.
* A formatter class of your own named `Md` or `Markdown` that inherits `Grape::Formatter::Base` now replaces Grape's globally, with a warning that `md` or `markdown` is already registered. Rename it, and declare it with `formatter :md, YourFormatter` (or `:markdown`) in the APIs that use it.

#### `logger` applies to the API it is set on

`logger` kept the logger in settings shared by the whole process, so setting it on one API replaced it on every other: two APIs that each set a logger both logged to whichever was set last.
Expand Down
11 changes: 11 additions & 0 deletions lib/grape/content_types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ module ContentTypes

MIME_TYPES = Grape::ContentTypes::DEFAULTS.except(:serializable_hash).invert.freeze

# Formats known by more than one name. A request naming one of them -- by
# extension, +?format=+ or +default_format+ -- is served under whichever
# the API declared, so declaring +:md+ answers +.markdown+ too, through
# the formatter, parser and error formatter declared for +:md+. Both
# spellings are keys: an extension arrives as a String, +default_format+
# as a Symbol.
FORMAT_SYNONYMS = {
md: :markdown, 'md' => :markdown,
markdown: :md, 'markdown' => :md
}.freeze

def content_types_for(from_settings)
from_settings.presence || DEFAULTS
end
Expand Down
17 changes: 17 additions & 0 deletions lib/grape/formatter/markdown.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Grape
module Formatter
class Markdown < Base
# Md inherits this, so the format named in the error comes from the class
# name, the way Grape::Util::Registry derives the name each class is
# registered under.
def self.call(object, _env)
return object.to_markdown if object.respond_to?(:to_markdown)
return object if object.is_a?(String)

raise Grape::Exceptions::InvalidFormatter.new(object.class, name.demodulize.underscore)
end
end
end
end
11 changes: 11 additions & 0 deletions lib/grape/formatter/md.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Grape
module Formatter
# Registers Markdown's rendering under +md+ too, for an API that declares
# +content_type :md+ rather than +:markdown+ (see
# Grape::ContentTypes::FORMAT_SYNONYMS).
class Md < Markdown
end
end
end
14 changes: 13 additions & 1 deletion lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,9 @@ def negotiate_content_type(env)
fmt = format_from_extension(env) || format_from_query(env) || format || format_from_header(env) || default_format
return env[Grape::Env::API_FORMAT] = fmt.to_sym if content_type_for(fmt)

synonym = declared_synonym(fmt)
return env[Grape::Env::API_FORMAT] = synonym if synonym

throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, message: "The requested format '#{fmt}' is not supported.")
end

Expand All @@ -319,7 +322,16 @@ def format_from_extension(env)
# A start and a length rather than an endless Range, which is an object
# of its own built on every call. A length past the end is clamped.
extension = try_scrub(request_path[dot_pos + 1, request_path.length])
extension if content_type_for(extension)
return extension if content_type_for(extension)

declared_synonym(extension)
end

# The name the API declared +fmt+ under when the request used another
# one for it (see Grape::ContentTypes::FORMAT_SYNONYMS), or nil.
def declared_synonym(fmt)
synonym = Grape::ContentTypes::FORMAT_SYNONYMS[fmt]
synonym if synonym && content_type_for(synonym)
end

# The extension is the tail of the request path, so PATH_INFO answers it
Expand Down
97 changes: 97 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1723,6 +1723,103 @@ def query_request(body, content_type: 'application/json')
end
end

context 'with a Markdown content_type' do
let(:page) do
Class.new do
def to_markdown
"# Hello\n"
end
end.new
end

markdown_names = %w[md markdown]

markdown_names.each do |declared|
context "declared as #{declared}" do
before do
markdown_page = page
subject.content_type :json, 'application/json'
subject.content_type declared, 'text/markdown; charset=utf-8'
subject.default_format :json

subject.get('/page') { markdown_page }
subject.get('/hash') { { foo: 'bar' } }
subject.get('/error') { error!('error in markdown', 422) }
subject.get('/api_format') { env[Grape::Env::API_FORMAT].to_s }
end

it 'renders #to_markdown for an Accept: text/markdown header' do
get '/page', {}, 'HTTP_ACCEPT' => 'text/markdown'
expect(last_response.content_type).to eql 'text/markdown; charset=utf-8'
expect(last_response.body).to eq "# Hello\n"
end

markdown_names.each do |extension|
context "with the #{extension} extension" do
it 'renders #to_markdown' do
get "/page.#{extension}"
expect(last_response.content_type).to eql 'text/markdown; charset=utf-8'
expect(last_response.body).to eq "# Hello\n"
end

it "negotiates the #{declared} format" do
get "/api_format.#{extension}"
expect(last_response.body).to eq declared
end

it 'answers in the default format when the body has no Markdown form' do
get "/hash.#{extension}"
expect(last_response).to be_successful
expect(last_response.content_type).to eql 'application/json'
expect(last_response.body).to eq '{"foo":"bar"}'
end

it 'renders an error as text' do
get "/error.#{extension}"
expect(last_response.status).to eq 422
expect(last_response.content_type).to eql 'text/markdown; charset=utf-8'
expect(last_response.body).to eq 'error in markdown'
end
end

it "renders #to_markdown for ?format=#{extension}" do
get '/page', format: extension
expect(last_response.content_type).to eql 'text/markdown; charset=utf-8'
expect(last_response.body).to eq "# Hello\n"
end
end
end
end

it 'formats the markdown extension with a formatter declared for md' do
subject.content_type :md, 'text/markdown; charset=utf-8'
subject.formatter :md, ->(object, _env) { "custom #{object}" }
subject.get('/page') { 'page' }

get '/page.markdown'
expect(last_response.body).to eq 'custom page'
end

context 'when none is declared' do
before do
subject.content_type :json, 'application/json'
subject.default_format :json
subject.get('/page') { 'page' }
end

it 'answers the markdown extension in the default format' do
get '/page.markdown'
expect(last_response.content_type).to eql 'application/json'
expect(last_response.body).to eq '"page"'
end

it 'rejects ?format=markdown' do
get '/page', format: 'markdown'
expect(last_response.status).to eq 406
end
end
end

context 'env["api.format"]' do
before do
ct = content_type
Expand Down
26 changes: 26 additions & 0 deletions spec/grape/formatter/markdown_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

describe Grape::Formatter::Markdown do
it 'is registered under :markdown' do
expect(Grape::Formatter.formatter_for(:markdown, nil)).to be(described_class)
end

describe '.call' do
it 'calls #to_markdown when the object responds to it' do
page = Class.new do
def to_markdown
"# Hello\n"
end
end.new
expect(described_class.call(page, {})).to eq("# Hello\n")
end

it 'returns a String as is' do
expect(described_class.call('# Hello', {})).to eq('# Hello')
end

it 'raises Grape::Exceptions::InvalidFormatter for any other object' do
expect { described_class.call({ a: 1 }, {}) }.to raise_error(Grape::Exceptions::InvalidFormatter, 'cannot convert Hash to markdown')
end
end
end
26 changes: 26 additions & 0 deletions spec/grape/formatter/md_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

describe Grape::Formatter::Md do
it 'is registered under :md' do
expect(Grape::Formatter.formatter_for(:md, nil)).to be(described_class)
end

describe '.call' do
it 'calls #to_markdown when the object responds to it' do
page = Class.new do
def to_markdown
"# Hello\n"
end
end.new
expect(described_class.call(page, {})).to eq("# Hello\n")
end

it 'returns a String as is' do
expect(described_class.call('# Hello', {})).to eq('# Hello')
end

it 'raises Grape::Exceptions::InvalidFormatter for any other object' do
expect { described_class.call({ a: 1 }, {}) }.to raise_error(Grape::Exceptions::InvalidFormatter, 'cannot convert Hash to md')
end
end
end
Loading