From a767a13e2715c9d6b60176b97cb81c031534a44d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 4 Aug 2026 15:12:11 +1200 Subject: [PATCH] Add parsers for common content types. --- lib/protocol/content/default.rb | 27 +++++++ lib/protocol/content/json_parser.rb | 51 +++++++++++++ lib/protocol/content/multipart_form_parser.rb | 53 ++++++++++++++ .../content/url_encoded_form_parser.rb | 38 ++++++++++ protocol-content.gemspec | 3 + readme.md | 27 +++++++ releases.md | 4 +- test/protocol/content/default.rb | 39 ++++++++++ test/protocol/content/json_parser.rb | 66 +++++++++++++++++ .../protocol/content/multipart_form_parser.rb | 73 +++++++++++++++++++ .../content/url_encoded_form_parser.rb | 48 ++++++++++++ 11 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 lib/protocol/content/default.rb create mode 100644 lib/protocol/content/json_parser.rb create mode 100644 lib/protocol/content/multipart_form_parser.rb create mode 100644 lib/protocol/content/url_encoded_form_parser.rb create mode 100644 test/protocol/content/default.rb create mode 100644 test/protocol/content/json_parser.rb create mode 100644 test/protocol/content/multipart_form_parser.rb create mode 100644 test/protocol/content/url_encoded_form_parser.rb diff --git a/lib/protocol/content/default.rb b/lib/protocol/content/default.rb new file mode 100644 index 0000000..8f63d2b --- /dev/null +++ b/lib/protocol/content/default.rb @@ -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 diff --git a/lib/protocol/content/json_parser.rb b/lib/protocol/content/json_parser.rb new file mode 100644 index 0000000..1ffcb79 --- /dev/null +++ b/lib/protocol/content/json_parser.rb @@ -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 diff --git a/lib/protocol/content/multipart_form_parser.rb b/lib/protocol/content/multipart_form_parser.rb new file mode 100644 index 0000000..2167188 --- /dev/null +++ b/lib/protocol/content/multipart_form_parser.rb @@ -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 diff --git a/lib/protocol/content/url_encoded_form_parser.rb b/lib/protocol/content/url_encoded_form_parser.rb new file mode 100644 index 0000000..6691108 --- /dev/null +++ b/lib/protocol/content/url_encoded_form_parser.rb @@ -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 diff --git a/protocol-content.gemspec b/protocol-content.gemspec index e7a0a35..8b17322 100644 --- a/protocol-content.gemspec +++ b/protocol-content.gemspec @@ -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 diff --git a/readme.md b/readme.md index 27c82b8..a4326c2 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/releases.md b/releases.md index 23f2cb8..b127af2 100644 --- a/releases.md +++ b/releases.md @@ -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. diff --git a/test/protocol/content/default.rb b/test/protocol/content/default.rb new file mode 100644 index 0000000..3d2006c --- /dev/null +++ b/test/protocol/content/default.rb @@ -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 diff --git a/test/protocol/content/json_parser.rb b/test/protocol/content/json_parser.rb new file mode 100644 index 0000000..e6c8b74 --- /dev/null +++ b/test/protocol/content/json_parser.rb @@ -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 diff --git a/test/protocol/content/multipart_form_parser.rb b/test/protocol/content/multipart_form_parser.rb new file mode 100644 index 0000000..5c2c7ed --- /dev/null +++ b/test/protocol/content/multipart_form_parser.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/content/multipart_form_parser" +require "protocol/content/representation" + +require "stringio" + +describe Protocol::Content::MultipartFormParser do + BOUNDARY = "example-boundary" + + def representation(body, boundary: BOUNDARY) + content_type = "multipart/form-data" + if boundary + content_type = "#{content_type}; boundary=#{boundary}" + end + + 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 + + def form_data + return <<~MULTIPART.gsub("\n", "\r\n") + --#{BOUNDARY} + Content-Disposition: form-data; name="user[name]" + + Samuel + --#{BOUNDARY}-- + MULTIPART + end + + it "parses nested multipart form data" do + parser = subject.new + + expect(parser.parse(representation(form_data))).to be == { + "user" => {"name" => "Samuel"}, + } + end + + it "populates a custom result" do + parser = subject.new + result = Protocol::URL::FormData::Nested.new + + expect(parser.call(representation(form_data), result)).to be == { + "user" => {"name" => "Samuel"}, + } + end + + it "incrementally enumerates form entries" do + parser = subject.new + + expect(parser.each(representation(form_data)).to_a).to be == [["user[name]", "Samuel"]] + end + + it "requires a boundary" do + parser = subject.new + + expect do + parser.parse(representation("", boundary: nil)) + end.to raise_exception(ArgumentError, message: be =~ /missing a boundary/) + end + + it "passes limits to the underlying parser" do + parser = subject.new(total_size_limit: 4) + + expect do + parser.parse(representation(form_data)) + end.to raise_exception(RangeError, message: be =~ /total_size exceeded limit of 4/) + end +end diff --git a/test/protocol/content/url_encoded_form_parser.rb b/test/protocol/content/url_encoded_form_parser.rb new file mode 100644 index 0000000..9319c52 --- /dev/null +++ b/test/protocol/content/url_encoded_form_parser.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/content/url_encoded_form_parser" +require "protocol/content/representation" + +require "stringio" + +describe Protocol::Content::URLEncodedFormParser do + def representation(body) + metadata = {"content-type" => "application/x-www-form-urlencoded"} + 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 nested form data" do + parser = subject.new + + expect(parser.parse(representation("user%5Bname%5D=Samuel&empty="))).to be == { + "user" => {"name" => "Samuel"}, + "empty" => "", + } + end + + it "populates a custom result" do + parser = subject.new + result = Protocol::URL::FormData::Nested.new + + expect(parser.call(representation("name=Samuel"), result)).to be == {"name" => "Samuel"} + end + + it "incrementally enumerates form pairs" do + parser = subject.new + representation = representation("one=1&two=2") + + expect(parser.each(representation).to_a).to be == [["one", "1"], ["two", "2"]] + end + + it "passes limits to the underlying parser" do + parser = subject.new(size_limit: 4) + + expect do + parser.parse(representation("name=Samuel")) + end.to raise_exception(RangeError, message: be =~ /size exceeded limit of 4/) + end +end