Skip to content
Open
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
27 changes: 27 additions & 0 deletions lib/protocol/content/default.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

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

require_relative "../content"
require_relative "json_parser"
require_relative "url_encoded_form_parser"
require_relative "multipart_form_parser"

module Protocol
module Content
class Parser
DEFAULT = build do |parser|
parser.register(JSONParser::CONTENT_TYPE, JSONParser.new)
parser.register(Protocol::URL::FormData::Parser::CONTENT_TYPE, URLEncodedFormParser.new)
parser.register(Protocol::Multipart::FormData::Parser::CONTENT_TYPE, MultipartFormParser.new)
end

# The default parser for common content types.
# @returns [Parser] The frozen default parser.
def self.default
return DEFAULT
end
end
end
end
51 changes: 51 additions & 0 deletions lib/protocol/content/json_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

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

require "json"

module Protocol
module Content
# Parses JSON representations with bounded input size and nesting depth.
class JSONParser
CONTENT_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 a JSON representation.
# @parameter representation [Representation] The JSON representation.
# @returns [Object] The decoded JSON value.
def parse(representation)
buffer = String.new.b

while chunk = representation.body.read
break if chunk.empty?

buffer << chunk
if @size_limit and buffer.bytesize > @size_limit
raise RangeError, "JSON content size exceeded limit of #{@size_limit}!"
end
end

return JSON.parse(buffer, **@options)
end

alias call parse
end
end
end
53 changes: 53 additions & 0 deletions lib/protocol/content/multipart_form_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

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

require "protocol/multipart/form_data/parser"

module Protocol
module Content
# Parses multipart form representations.
class MultipartFormParser
# Initialize the multipart form parser.
# @parameter options [Hash] Options passed to `Protocol::Multipart::FormData::Parser`.
def initialize(**options)
@parser = Protocol::Multipart::FormData::Parser.new(**options)
end

# Parse a multipart form representation.
# @parameter representation [Representation] The multipart representation.
# @parameter arguments [Array] Optional arguments passed to the underlying parser.
# @yields {|name, value| ...} Each form entry before assignment.
# @returns [Hash] The nested form data.
def parse(representation, *arguments, &block)
return @parser.parse(
representation.body,
*arguments,
boundary: boundary(representation),
&block
)
end

alias call parse

# Incrementally enumerate multipart form entries.
# @parameter representation [Representation] The multipart representation.
# @yields {|name, value| ...} Each form field name and value.
# @returns [Enumerator | Boolean] An enumerator without a block, or true when complete.
def each(representation, &block)
return @parser.each(representation.body, boundary: boundary(representation), &block)
end

private

def boundary(representation)
if boundary = representation.content_type&.parameters&.[]("boundary")
return boundary
end

raise ArgumentError, "Multipart content type is missing a boundary!"
end
end
end
end
38 changes: 38 additions & 0 deletions lib/protocol/content/url_encoded_form_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

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

require "protocol/url/form_data/parser"

module Protocol
module Content
# Parses URL-encoded form representations.
class URLEncodedFormParser
# Initialize the URL-encoded form parser.
# @parameter options [Hash] Options passed to `Protocol::URL::FormData::Parser`.
def initialize(**options)
@parser = Protocol::URL::FormData::Parser.new(**options)
end

# Parse a URL-encoded form representation.
# @parameter representation [Representation] The form representation.
# @parameter arguments [Array] Optional arguments passed to the underlying parser.
# @yields {|name, value| ...} Each decoded form pair before assignment.
# @returns [Hash] The nested form data.
def parse(representation, *arguments, &block)
return @parser.parse(representation.body, *arguments, &block)
end

alias call parse

# Incrementally enumerate URL-encoded form pairs.
# @parameter representation [Representation] The form representation.
# @yields {|name, value| ...} Each decoded form pair.
# @returns [Enumerator | Boolean] An enumerator without a block, or true when complete.
def each(representation, &block)
return @parser.each(representation.body, &block)
end
end
end
end
3 changes: 3 additions & 0 deletions protocol-content.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = ">= 3.3"

spec.add_dependency "json", "~> 2.0"
spec.add_dependency "protocol-media", "~> 0.1"
spec.add_dependency "protocol-multipart", "~> 0.4"
spec.add_dependency "protocol-url", "~> 0.8"
end
27 changes: 27 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ representation.value

Parsing is lazy and memoized by each representation. Registered handlers receive the complete representation so they can inspect media-type parameters or stream the body when appropriate.

### Default Parsers

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

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

representation = Protocol::Content::Representation.for(request, parser: Protocol::Content::Parser.default)
value = representation.value
```

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

Format parsers can also 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", json_parser)
end
```

Multipart fields are parsed by the default parser. File uploads require an explicit `MultipartFormParser#each` or `#parse` block so applications can consume each upload while it is available without imposing a tempfile policy.

## Releases

Please see the [project releases](https://github.com/socketry/protocol-content/releases) for all releases.
Expand Down
4 changes: 2 additions & 2 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

## Unreleased

### Added

- Add symmetric request and response content representations with media-type parser dispatch.
- Add JSON, URL-encoded form, and multipart form parsers with explicit convenient defaults.
- Bound JSON input size and nesting depth using consistently named limits.
39 changes: 39 additions & 0 deletions test/protocol/content/default.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

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

require "protocol/content/default"

require "stringio"

describe Protocol::Content::Parser do
BOUNDARY = "example-boundary"

def representation(content_type, body)
metadata = {"content-type" => content_type}
content_type = Protocol::Media::Type.for(content_type)
return Protocol::Content::Representation.new(metadata, StringIO.new(body), content_type: content_type)
end

it "provides a frozen default parser" do
parser = subject.default

expect(parser).to be(:frozen?)
expect(parser.parse(representation("application/json", "{}"))).to be == {}
expect(parser.parse(representation("application/x-www-form-urlencoded", "name=Samuel"))).to be == {"name" => "Samuel"}
end

it "parses multipart form data by default" do
body = <<~MULTIPART.gsub("\n", "\r\n")
--#{BOUNDARY}
Content-Disposition: form-data; name="name"

Samuel
--#{BOUNDARY}--
MULTIPART

content_type = "multipart/form-data; boundary=#{BOUNDARY}"
expect(subject.default.parse(representation(content_type, body))).to be == {"name" => "Samuel"}
end
end
66 changes: 66 additions & 0 deletions test/protocol/content/json_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

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

require "protocol/content/json_parser"
require "protocol/content/representation"

require "stringio"

describe Protocol::Content::JSONParser do
def representation(body)
metadata = {"content-type" => "application/json"}
content_type = Protocol::Media::Type.for(metadata["content-type"])
return Protocol::Content::Representation.new(metadata, StringIO.new(body), content_type: content_type)
end

it "parses JSON" do
parser = subject.new(symbolize_names: true)

expect(parser.parse(representation('{"name":"Samuel"}'))).to be == {name: "Samuel"}
end

it "is callable" do
parser = subject.new

expect(parser.call(representation("null"))).to be_nil
end

it "limits the encoded document size" do
parser = subject.new(size_limit: 4)

expect do
parser.parse(representation("[1,2]"))
end.to raise_exception(RangeError, message: be =~ /exceeded limit of 4/)
end

it "allows the size limit to be disabled" do
parser = subject.new(size_limit: nil)

expect(parser.parse(representation("[1,2]"))).to be == [1, 2]
end

it "limits the document nesting depth" do
parser = subject.new(depth_limit: 1)

expect do
parser.parse(representation('{"outer":{"inner":true}}'))
end.to raise_exception(JSON::NestingError)
end

it "limits document nesting depth by default" do
json = ("[" * 33) + "null" + ("]" * 33)

expect do
subject.new.parse(representation(json))
end.to raise_exception(JSON::NestingError)
end

it "allows the depth limit to be disabled" do
parser = subject.new(depth_limit: nil)
json = ("[" * 101) + "null" + ("]" * 101)

expect(parser.parse(representation(json))).to be_a(Array)
end
end
Loading
Loading