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: 0 additions & 1 deletion gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
gem "sus"

gem "bake-test"
gem "protocol-http", "~> 0.67"
end

gem "rubocop", "~> 1.88", group: :test
Expand Down
114 changes: 114 additions & 0 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Getting Started

This guide explains how to parse media-typed content using built-in and custom parsers.

## Installation

Add the gem to your project:

~~~ bash
$ bundle add protocol-content
~~~

## Parse Content

A {ruby Protocol::Content::Parser} selects an interpretation according to the media type:

``` ruby
require "protocol/content"
require "json"

parser = Protocol::Content::Parser.build do |parser|
parser.register("application/json") do |input|
JSON.parse(input.read)
end
end

value = File.open("document.json") do |input|
parser.parse("application/json", input)
end
```

The caller owns media-type extraction and adapts the encoded body to a readable IO-like input. This keeps parsing independent of request, response, and transport abstractions. Registered handlers receive the readable input and parsed {ruby Protocol::Media::Type}. Applications decide whether and where parsed values should be memoized.

## Parse Protocol HTTP Bodies

A `Protocol::HTTP::Body::Readable` can be adapted to a readable stream using `#to_io`. The caller must close that stream after parsing, passing through any parser error:

``` ruby
media_type = request.headers["content-type"]
input = request.body.to_io

begin
value = parser.parse(media_type, input)
rescue => error
raise
ensure
input.close_read(error)
end
```

This example specifically assumes a `Protocol::HTTP` body. The stream's `#close_read` method releases its input buffer and invokes `#close(error)` on the underlying body. Generic IO-like parser inputs do not necessarily provide this interface.

## Default Parsers

The default parser supports JSON, URL-encoded forms, and multipart forms with bounded defaults:

``` ruby
require "protocol/content/default"

value = Protocol::Content::Parser.default.parse(
media_type,
input,
)
```

The format libraries are included as dependencies, so these defaults are available from a normal installation.

## Configure Limits

Format parsers can be configured explicitly for endpoint-specific limits:

``` ruby
require "protocol/content/json_parser"

json_parser = Protocol::Content::JSONParser.new(
size_limit: 4 * 1024 * 1024,
depth_limit: 32,
)

parser = Protocol::Content::Parser.build do |parser|
parser.register("application/json") do |input|
json_parser.parse(input)
end
end
```

Limits are inclusive. Content at the configured limit is accepted, while content exceeding it raises {ruby Protocol::Content::ContentTooLargeError}.

## Stream Multipart Uploads

Multipart fields can be collected into {ruby Protocol::URL::FormData::Nested} while file uploads are streamed to application-managed storage. The value returned by the block is assigned to the nested result:

``` ruby
require "protocol/content/default"

form_data = Protocol::Content::Parser.default.parse(media_type, input) do |name, value|
case value
when Protocol::Multipart::FormData::Upload
upload = uploads.create(name, value.filename, value.headers)

value.each do |chunk|
upload.write(chunk)
end

upload
when String
value
end
end
```

Here, `uploads` represents an application storage interface. The parser yields a `String` for each buffered field and a {ruby Protocol::Multipart::FormData::Upload} for each file upload. The value returned by the block is stored in the nested result.

An upload can only be read during its block invocation. After the block returns, the parser consumes and discards any unread upload bytes while continuing to enforce the upload and total size limits. Applications must therefore consume uploads within the block when they need to persist their contents.
2 changes: 2 additions & 0 deletions guides/links.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
getting-started:
order: 1
1 change: 0 additions & 1 deletion lib/protocol/content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
require_relative "content/version"
require_relative "content/error"
require_relative "content/parser"
require_relative "content/representation"

module Protocol
# @namespace
Expand Down
47 changes: 47 additions & 0 deletions lib/protocol/content/default.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require_relative "../content"
require_relative "json_parser"

require "protocol/url/form_data/parser"
require "protocol/multipart/form_data/parser"

module Protocol
module Content
class Parser
DEFAULT = build do |parser|
json_parser = JSONParser.new
parser.register(JSONParser::MEDIA_TYPE) do |input|
json_parser.parse(input)
end

url_encoded_form_parser = Protocol::URL::FormData::Parser.new
parser.register(Protocol::URL::FormData::Parser::MEDIA_TYPE) do |input, _media_type, &block|
url_encoded_form_parser.parse(input, &block)
rescue Protocol::URL::LimitError
raise ContentTooLargeError
end

multipart_form_parser = Protocol::Multipart::FormData::Parser.new
parser.register(Protocol::Multipart::FormData::Parser::MEDIA_TYPE) do |input, media_type, &block|
if boundary = media_type.parameters["boundary"]
multipart_form_parser.parse(input, boundary: boundary, &block)
else
raise ArgumentError, "Multipart media type is missing a boundary!"
end
rescue Protocol::Multipart::LimitError, Protocol::URL::LimitError
raise ContentTooLargeError
end
end

# The default parser for common media types.
# @returns [Parser] The frozen default parser.
def self.default
return DEFAULT
end
end
end
end
12 changes: 10 additions & 2 deletions lib/protocol/content/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@ module Content
class Error < StandardError
end

# Raised when no parser accepts a representation's media type.
# Raised when content cannot be parsed.
class ParseError < Error
end

# Raised when content exceeds a configured parser limit.
class ContentTooLargeError < ParseError
end

# Raised when no parser accepts a media type.
class UnsupportedMediaTypeError < Error
# Initialize the error.
# @parameter media_type [Protocol::Media::Type | Nil] The unsupported media type.
def initialize(media_type)
if media_type
super("Unsupported media type: #{media_type}")
else
super("Missing content type!")
super("Missing media type!")
end

@media_type = media_type
Expand Down
62 changes: 62 additions & 0 deletions lib/protocol/content/json_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "json"

require_relative "error"

module Protocol
module Content
# Parses JSON content with bounded input size and nesting depth.
class JSONParser
MEDIA_TYPE = "application/json"

# The encoded JSON document size limit.
SIZE_LIMIT = 2 * 1024 * 1024

# The JSON document nesting depth limit.
DEPTH_LIMIT = 32

# Initialize the JSON parser.
# @parameter size_limit [Integer | Nil] The encoded document size limit.
# @parameter depth_limit [Integer | Nil] The document nesting depth limit.
# @parameter options [Hash] Options passed to `JSON.parse`.
def initialize(size_limit: SIZE_LIMIT, depth_limit: DEPTH_LIMIT, **options)
@size_limit = size_limit
options[:max_nesting] = depth_limit || false
@options = options
end

# Parse JSON content.
# @parameter input [Object] The readable input.
# @returns [Object] The decoded JSON value.
def parse(input)
if @size_limit
buffer = String.new.b

# Read up to the size limit, allowing for partial reads:
while buffer.bytesize < @size_limit
chunk = input.read(@size_limit - buffer.bytesize)
break unless chunk
# An empty chunk cannot make progress, so stop reading:
break if chunk.empty?

buffer << chunk
end

if buffer.bytesize == @size_limit && input.read(1)
raise ContentTooLargeError, "JSON content size exceeded limit of #{@size_limit}!"
end
else
buffer = input.read
end

return JSON.parse(buffer, **@options)
rescue JSON::NestingError
raise ContentTooLargeError
end
end
end
end
26 changes: 15 additions & 11 deletions lib/protocol/content/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
# Copyright, 2026, by Samuel Williams.

require "protocol/media/map"
require "protocol/media/type"

require_relative "error"

module Protocol
module Content
# Selects a representation parser according to its media type.
# Selects a content parser according to its media type.
class Parser
# Build and freeze a parser.
# @yields {|parser| ...} The mutable parser being configured.
Expand All @@ -28,9 +29,10 @@ def initialize

# Register a handler for a media type or range.
# @parameter media_range [String | Protocol::Media::Range] The accepted media type or range.
# @parameter handler [#call | Nil] The representation handler.
# @yields {|representation| ...} The representation to parse.
# @parameter representation [Representation] The encoded representation.
# @parameter handler [#call | Nil] The content handler.
# @yields {|input, media_type| ...} The content to parse.
# @parameter input [Object] The readable input.
# @parameter media_type [Protocol::Media::Type] The parsed media type.
# @returns [#call] The registered handler.
def register(media_range, handler = nil, &block)
if handler && block
Expand All @@ -40,21 +42,23 @@ def register(media_range, handler = nil, &block)
handler ||= block

unless handler&.respond_to?(:call)
raise ArgumentError, "A representation handler must respond to #call!"
raise ArgumentError, "A content handler must respond to #call!"
end

@handlers[media_range] = handler
return handler
end

# Parse a representation using the handler matching its media type.
# @parameter representation [Representation] The encoded representation.
# @returns [Object] The parsed representation value.
def parse(representation)
media_type = representation.content_type
# Parse content using the handler matching its media type.
# @parameter media_type [String | Protocol::Media::Type | Nil] The media type.
# @parameter input [Object] The readable input.
# @yields {...} An optional block forwarded to the selected content handler.
# @returns [Object] The parsed content value.
def parse(media_type, input, &block)
media_type = Protocol::Media::Type.for(media_type)

if media_type && handler = @handlers[media_type]
return handler.call(representation)
return handler.call(input, media_type, &block)
end

raise UnsupportedMediaTypeError, media_type
Expand Down
Loading
Loading