Skip to content

Add a built-in :jsonl (JSON Lines) format - #3029

Merged
ericproulx merged 1 commit into
masterfrom
feature/jsonl-format
Oct 19, 2026
Merged

ericproulx merged 1 commit into
masterfrom
feature/jsonl-format

Conversation

@ericproulx

@ericproulx ericproulx commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds JSON Lines (jsonlines.org) as a built-in format: a formatter, a parser and an error formatter registered under :jsonl. Like any format outside the defaults, an API declares it with content_type.

class API < Grape::API
  content_type :jsonl, 'application/jsonl'
  format :jsonl

  get :users do
    User.all # {"id":1,"name":"Ada"}\n{"id":2,"name":"Grace"}\n
  end

  get :export do
    User.find_each # an Enumerator: streamed, one line at a time
  end
end
  • Response: an Array renders one line per element and any other object one line, each as Grape::Formatter::Json renders it (so a Grape::PrecompiledJson element is served verbatim as its line). A top-level String is served as it is, taken to be JSON Lines already, as :serializable_hash does.
  • Streaming: an Enumerator body renders lazily: each element is encoded only when the server reads the body, and each line goes out as its own chunk. To support this, a formatter may now return a Grape::ServeStream::StreamResponse, which the formatter middleware serves the same way it serves stream. stream itself is unchanged and still writes raw chunks.
  • Request: a JSON Lines body parses into an Array of its lines' values. Blank lines are skipped, so a trailing newline and CRLF line ends both parse, and a line that is not JSON answers 400 (InvalidMessageBody).
  • Errors render as one JSON line (the JSON error formatter's output followed by a newline).

Design choices

  • Kept out of ContentTypes::DEFAULTS. DEFAULTS is what every API that declares no content type negotiates. Adding jsonl there would make all of those APIs answer Accept: application/jsonl and /path.jsonl in JSON Lines, accept application/jsonl bodies where they answer 415 today, and add the type to grape-swagger's produces. An API declares it with content_type :jsonl, ... like any other format outside the defaults, so format :jsonl alone raises MissingMimeType. No new mechanism lets format skip that declaration.
  • The parser returns an Array, not a Hash. A JSON body that is an Array is treated the same way: it is available in env['api.request.body'] and not merged into params. Wrapping the lines under an invented key would introduce a convention nobody asked for. The README shows a one-line custom parser that wraps them for params validation.
  • Media type application/jsonl. No media type is registered for JSON Lines. application/jsonl matches the format name and the .jsonl extension, and the README declares it; an API whose clients expect application/x-ndjson declares that instead.

Upgrading

An app that already declared content_type :jsonl, ... itself, without its own formatter, parser or error formatter for it, now gets Grape's. Its String bodies are still served as they are, but a request body now arrives as an Array instead of the raw String (parser :jsonl, nil keeps the String), and errors render as a JSON line instead of through default_error_formatter. Covered in UPGRADING.

Caveats

  • A streamed response has sent its status and headers before its first line is rendered, so an exception raised mid-stream is past rescue_from and the response simply ends early. This is documented in the README, and stream behaves the same way today.
  • On Rack 2.2, Rack::ETag (which Rails includes) buffers any body that does not respond to to_path, so it reads a streamed body in full. Rack 3's ETag only digests to_ary bodies. stream is affected the same way.

Test plan

  • New specs: spec/grape/formatter/jsonl_spec.rb, spec/grape/parser/jsonl_spec.rb, spec/grape/error_formatter/jsonl_spec.rb. The streaming spec drives the API through Rack and checks that nothing is rendered before the body is read and that each element arrives as its own chunk.
  • Mutation-checked: making the stream eager, dropping the Enumerator branch, the String pass-through or the trailing newline, skipping the middleware's stream check, dropping blank-line skipping, and dropping null/false lines each fail at least one spec.
  • bundle exec rake (RuboCop + full RSpec suite) passes on Ruby 4.0.7.
  • The new and related specs pass with gemfiles/rack_2_2.gemfile (Rack 2.2.24).
  • CI green.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the feature/jsonl-format branch from 79ef5de to 2e73bcd Compare October 10, 2026 23:02
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@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.

The OPT_IN name is strange. All we are trying to do is to introduce a non-default content-type? Should it be some kind of known type instead?

@ericproulx
ericproulx force-pushed the feature/jsonl-format branch from 2e73bcd to 8a6de8c Compare October 16, 2026 19:13
@ericproulx

Copy link
Copy Markdown
Contributor Author

The OPT_IN name is strange. All we are trying to do is to introduce a non-default content-type? Should it be some kind of known type instead?

I removed the OPT_IN

@ericproulx
ericproulx requested a review from dblock October 16, 2026 19:16
@ericproulx
ericproulx force-pushed the feature/jsonl-format branch 5 times, most recently from caef3d9 to a463a13 Compare October 17, 2026 19:28
`format :jsonl` serves JSON Lines: an Array renders one line per element
and any other object one line, each as the JSON formatter renders it; a
String is served as it is. An Enumerator body is streamed, each element
rendered only as the server reads the body, so a large export never sits
in memory whole. A formatter now signals that by handing back a
Grape::ServeStream::StreamResponse, which the formatter middleware serves
the way it serves `stream`.

A JSON Lines request body parses into an Array of its lines' values,
skipping blank lines, and stays in env['api.request.body'] like a JSON
Array body. Errors render as one JSON line.

:jsonl is kept out of ContentTypes::DEFAULTS, which every API that
declares no content type negotiates. An API declares it like any other
format, `content_type :jsonl, 'application/jsonl'` (or
application/x-ndjson), before `format :jsonl`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the feature/jsonl-format branch from a463a13 to bc9e103 Compare October 17, 2026 20:42
@ericproulx
ericproulx merged commit ae39fbb into master Oct 19, 2026
69 checks passed
@ericproulx
ericproulx deleted the feature/jsonl-format branch October 19, 2026 11:48
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