Skip to content

Add :md and :markdown formatters that render an object's to_markdown - #3025

Merged
ericproulx merged 1 commit into
masterfrom
feat/markdown-formatter
Oct 19, 2026
Merged

ericproulx merged 1 commit into
masterfrom
feat/markdown-formatter

Conversation

@ericproulx

@ericproulx ericproulx commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Rails 8.1 registers text/markdown as Mime[:md] and adds render markdown: obj, which calls obj.to_markdown (renderers.rb). Action Text adds to_markdown to Content, Fragment, RichText and Attachment on Rails main (#56858, #56894). This gives Grape the same contract, so an object written for a Rails controller renders the same way in a Grape API.

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, ?format=md, ?format=markdown and Accept: text/markdown all answer text/markdown.

Grape::Formatter::Md and Grape::Formatter::Markdown

Body Result
Answers to_markdown object.to_markdown
String as is
Anything else Grape::Exceptions::InvalidFormatter: the default format when the client negotiated Markdown, 500 when the API pins format :md

Notes

  • Opt-in. Neither :md nor :markdown is added to Grape::ContentTypes::DEFAULTS. Doing so would switch .md requests and Accept: text/markdown, which answer :txt today, on every API that declares no content types. That can wait for a major release.
  • One declaration serves .md and .markdown. Both are common extensions for Markdown, so :md and :markdown name the same format (Grape::ContentTypes::FORMAT_SYNONYMS). When a request asks for a format the API did not declare (by extension, ?format= or default_format), the Formatter middleware tries its synonym and negotiates the name the API declared. A .markdown request to an API that declared :md negotiates :md, so a formatter :md of the API's own renders it too. Requests for a declared format never reach the lookup. Grape::Formatter::Md is an empty subclass of Markdown, so the registry, which derives each key from the class name, registers the same rendering under both. The InvalidFormatter message names the format the same way, so each class reports its own.
  • Raises like :xml rather than falling back to to_s like :txt. A client asking for Markdown from an endpoint that returns a Hash gets the default format through the existing negotiated-format fallback, not the Hash's #to_s. Rails passes such an object through unchanged.
  • No Grape::Md backend module, parser or error formatter. Grape::Json and Grape::Xml exist to choose a library Grape encodes and decodes with. Grape never produces or parses Markdown itself. A text/markdown request body stays in env['api.request.input'], and errors go through the text error formatter, since plain text is valid Markdown.
  • charset. RFC 7763 makes it a required parameter of text/markdown, so the README declares text/markdown; charset=utf-8. Negotiation still matches, because Grape compares media types without their parameters.
  • Action Text attachments. Called without arguments, to_markdown defaults to attachment_links: false, so blob attachments render as plain-text placeholders and need no rendering context. Real links need to_markdown(attachment_links: true) called in the endpoint, inside a Rails rendering context.
  • UPGRADING covers the three ways an existing app could notice: an API that already declared content_type :md without a formatter used to send bodies unchanged, an API that declared one name used to answer the other extension in its default format (or 406 for ?format=), and a user formatter class named Md or Markdown now replaces Grape's with a warning.

Verified with full bundle exec rubocop and bundle exec rspec (3153 examples, 0 failures).

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the feat/markdown-formatter branch from 3c7100d to 860f03e Compare October 10, 2026 22:33
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock October 10, 2026 22:38
@ericproulx
ericproulx marked this pull request as ready for review October 10, 2026 22:38

@dblock dblock left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both .md and .markdown are supported extensions, similarly I think we may want to alias one to the other in class names, keywords, etc?

@ericproulx
ericproulx force-pushed the feat/markdown-formatter branch from 860f03e to 93401e3 Compare October 16, 2026 19:00
@ericproulx ericproulx changed the title Add an :md formatter that renders an object's to_markdown Add :md and :markdown formatters that render an object's to_markdown Oct 16, 2026
@ericproulx
ericproulx force-pushed the feat/markdown-formatter branch 6 times, most recently from 044b039 to 014f61c Compare October 17, 2026 20:42
@dblock

dblock commented Oct 19, 2026

Copy link
Copy Markdown
Member

Could we drop the FORMAT constants and derive the format name from the class name, matching how the formatter registry already derives its keys? That keeps the shared implementation without overriding a constant:

class Markdown < Base
  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

class Md < Markdown
end

Both classes register automatically and report their own format name in errors. The name derivation only happens on the error path.

— AI posting on behalf of @dblock

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 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the feat/markdown-formatter branch from 014f61c to 00f9af8 Compare October 19, 2026 11:52
@ericproulx

Copy link
Copy Markdown
Contributor Author

Done in 00f9af8. Markdown < Base now holds call, Md < Markdown is empty, and the FORMAT constants are gone. The error names the format with name.demodulize.underscore, so Md reports md and Markdown reports markdown, which the formatter specs still check. I also rebased onto master after #3029.

@ericproulx
ericproulx merged commit 0342ddc into master Oct 19, 2026
70 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants