From 00f9af8e34a47debfc9b8a3653aa3c71f5b6fc77 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Sun, 11 Oct 2026 00:33:26 +0200 Subject: [PATCH] Add :md and :markdown formatters that render an object's to_markdown Rails 8.1 registers text/markdown as Mime[:md] and adds `render markdown: obj`, which calls obj.to_markdown. Grape::Formatter::Markdown is the same contract for Grape: an object answering to_markdown is rendered with it and a String is returned as is. Anything else raises Grape::Exceptions::InvalidFormatter, so a client that negotiated Markdown for a Hash body gets the default format instead of its #to_s. .md and .markdown are both common Markdown extensions, so :md and :markdown name the same format. Declaring either content type answers both extensions and both ?format= spellings: the Formatter middleware resolves the other name through ContentTypes::FORMAT_SYNONYMS to the one the API declared, so a formatter, parser or error formatter declared for that name applies too. The lookup runs only when the requested format is not declared, so the common path is unchanged. Grape::Formatter::Md, an empty subclass of Markdown, registers the same rendering under :md. Each class names its own format in the InvalidFormatter message, derived from the class name the way the registry derives its key. Neither is a default content type; an API opts in with `content_type :md, 'text/markdown; charset=utf-8'`. Errors keep the text error formatter, and a text/markdown request body passes through unparsed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 24 +++++++ UPGRADING.md | 9 +++ lib/grape/content_types.rb | 11 +++ lib/grape/formatter/markdown.rb | 17 +++++ lib/grape/formatter/md.rb | 11 +++ lib/grape/middleware/formatter.rb | 14 +++- spec/grape/api_spec.rb | 97 +++++++++++++++++++++++++++ spec/grape/formatter/markdown_spec.rb | 26 +++++++ spec/grape/formatter/md_spec.rb | 26 +++++++ 10 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 lib/grape/formatter/markdown.rb create mode 100644 lib/grape/formatter/md.rb create mode 100644 spec/grape/formatter/markdown_spec.rb create mode 100644 spec/grape/formatter/md_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index eddda6c35..034d240cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c11c3b260..8723ca5e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/UPGRADING.md b/UPGRADING.md index 210034613..dfe1e6912 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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. diff --git a/lib/grape/content_types.rb b/lib/grape/content_types.rb index 6669af7a5..5f808b20f 100644 --- a/lib/grape/content_types.rb +++ b/lib/grape/content_types.rb @@ -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 diff --git a/lib/grape/formatter/markdown.rb b/lib/grape/formatter/markdown.rb new file mode 100644 index 000000000..84e4fbae9 --- /dev/null +++ b/lib/grape/formatter/markdown.rb @@ -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 diff --git a/lib/grape/formatter/md.rb b/lib/grape/formatter/md.rb new file mode 100644 index 000000000..a50859320 --- /dev/null +++ b/lib/grape/formatter/md.rb @@ -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 diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 1d0a4832b..efb2a37c8 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -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 @@ -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 diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 9acd20455..16ca087de 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -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 diff --git a/spec/grape/formatter/markdown_spec.rb b/spec/grape/formatter/markdown_spec.rb new file mode 100644 index 000000000..2a8031966 --- /dev/null +++ b/spec/grape/formatter/markdown_spec.rb @@ -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 diff --git a/spec/grape/formatter/md_spec.rb b/spec/grape/formatter/md_spec.rb new file mode 100644 index 000000000..75ecdfd56 --- /dev/null +++ b/spec/grape/formatter/md_spec.rb @@ -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