diff --git a/gems.rb b/gems.rb index 3698039..5eaf364 100644 --- a/gems.rb +++ b/gems.rb @@ -21,7 +21,6 @@ gem "sus" gem "bake-test" - gem "protocol-http", "~> 0.67" end gem "rubocop", "~> 1.88", group: :test diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md new file mode 100644 index 0000000..8d1f186 --- /dev/null +++ b/guides/getting-started/readme.md @@ -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. diff --git a/guides/links.yaml b/guides/links.yaml new file mode 100644 index 0000000..7f527b0 --- /dev/null +++ b/guides/links.yaml @@ -0,0 +1,2 @@ +getting-started: + order: 1 diff --git a/lib/protocol/content.rb b/lib/protocol/content.rb index 7703761..51472e6 100644 --- a/lib/protocol/content.rb +++ b/lib/protocol/content.rb @@ -6,7 +6,6 @@ require_relative "content/version" require_relative "content/error" require_relative "content/parser" -require_relative "content/representation" module Protocol # @namespace diff --git a/lib/protocol/content/default.rb b/lib/protocol/content/default.rb new file mode 100644 index 0000000..56ec127 --- /dev/null +++ b/lib/protocol/content/default.rb @@ -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 diff --git a/lib/protocol/content/error.rb b/lib/protocol/content/error.rb index 174c5ea..d8b18c6 100644 --- a/lib/protocol/content/error.rb +++ b/lib/protocol/content/error.rb @@ -9,7 +9,15 @@ 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. @@ -17,7 +25,7 @@ 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 diff --git a/lib/protocol/content/json_parser.rb b/lib/protocol/content/json_parser.rb new file mode 100644 index 0000000..492710b --- /dev/null +++ b/lib/protocol/content/json_parser.rb @@ -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 diff --git a/lib/protocol/content/parser.rb b/lib/protocol/content/parser.rb index d7acd5b..6ce5665 100644 --- a/lib/protocol/content/parser.rb +++ b/lib/protocol/content/parser.rb @@ -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. @@ -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 @@ -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 diff --git a/lib/protocol/content/representation.rb b/lib/protocol/content/representation.rb deleted file mode 100644 index 4c95790..0000000 --- a/lib/protocol/content/representation.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "protocol/media/type" - -require_relative "parser" - -module Protocol - module Content - # A representation consists of encoded data and metadata describing that data. - class Representation - PARSER = Parser.new.freeze - - # Construct a representation specialization using the given parser. - # @parameter parser [Parser] The parser used to decode representation values. - # @returns [Class] A configured representation subclass. - def self.[](parser) - klass = Class.new(self) - klass.const_set(:PARSER, parser) - return klass - end - - # The parser configured for this representation class. - # @returns [Parser] The configured parser. - def self.parser - self.const_get(:PARSER) - end - - # Construct a representation from a request or response message. - # @parameter message [Object] A message exposing `headers` and `body`. - # @parameter parser [Parser] The parser used to decode the representation. - # @returns [Representation] The representation carried by the message. - def self.for(message, parser: self.parser) - metadata = message.headers - content_type = Protocol::Media::Type.for(metadata["content-type"]) - - return self.new(metadata, message.body, content_type: content_type, parser: parser) - end - - # Initialize a representation. - # @parameter metadata [Object] The representation metadata. - # @parameter body [Object | Nil] The encoded representation data. - # @parameter content_type [Protocol::Media::Type | Nil] The representation media type. - # @parameter parser [Parser] The parser used to decode the representation. - def initialize(metadata, body, content_type: nil, parser: self.class.parser) - @metadata = metadata - @body = body - @content_type = content_type - @parser = parser - @value = nil - @parsed = false - end - - # Metadata describing the representation. - attr :metadata - - # The encoded representation body. - attr :body - - # The parser used to decode the representation. - attr :parser - - # The representation media type described by its metadata. - # @returns [Protocol::Media::Type | Nil] The content type, if present. - attr :content_type - - alias media_type content_type - - # Decode and memoize the representation value. - # @returns [Object] The decoded value. - def value - unless @parsed - @value = @parser.parse(self) - @parsed = true - end - - return @value - end - - # Access the decoded representation value. - def [](...) - return self.value.[](...) - end - end - end -end diff --git a/protocol-content.gemspec b/protocol-content.gemspec index e7a0a35..855fb18 100644 --- a/protocol-content.gemspec +++ b/protocol-content.gemspec @@ -6,7 +6,7 @@ Gem::Specification.new do |spec| spec.name = "protocol-content" spec.version = Protocol::Content::VERSION - spec.summary = "Provides abstractions for media-typed content representations." + spec.summary = "Provides parsing for media-typed content." spec.authors = ["Samuel Williams"] spec.license = "MIT" @@ -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.6" + spec.add_dependency "protocol-url", "~> 0.10" end diff --git a/readme.md b/readme.md index 27c82b8..b293cd4 100644 --- a/readme.md +++ b/readme.md @@ -1,37 +1,14 @@ # Protocol::Content -Provides transport-independent abstractions for media-typed content representations. +Provides transport-independent parsing for media-typed content. [![Development Status](https://github.com/socketry/protocol-content/workflows/Test/badge.svg)](https://github.com/socketry/protocol-content/actions?workflow=Test) ## Usage -A representation associates encoded data with metadata describing that data. A parser selects an interpretation according to the representation's media type: +Please see the [project documentation](https://socketry.github.io/protocol-content/) for more details. -```ruby -require "protocol/content" -require "json" - -parser = Protocol::Content::Parser.build do |parser| - parser.register("application/json") do |representation| - JSON.parse(representation.body.join) - end -end - -JSONRepresentation = Protocol::Content::Representation[parser] -``` - -Representations can be constructed symmetrically from request and response messages. The message only needs to expose `headers` and `body`: - -```ruby -representation = JSONRepresentation.for(request) -representation["user"]["name"] - -representation = JSONRepresentation.for(response) -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. + - [Getting Started](https://socketry.github.io/protocol-content/guides/getting-started/index) - This guide explains how to parse media-typed content using built-in and custom parsers. ## Releases diff --git a/releases.md b/releases.md index 23f2cb8..d02bae3 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,8 @@ ## Unreleased -### Added - -- Add symmetric request and response content representations with media-type parser dispatch. +- Add media-type parser dispatch for readable content. +- Add JSON, URL-encoded form, and multipart form parsers with explicit convenient defaults. +- Bound JSON input size and nesting depth using consistently named limits. +- Add `ContentTooLargeError` for content parser limit violations. +- Forward blocks to content handlers for incremental and streaming parsing. diff --git a/test/protocol/content.rb b/test/protocol/content.rb index 1ad1b78..35a806c 100644 --- a/test/protocol/content.rb +++ b/test/protocol/content.rb @@ -10,8 +10,4 @@ expect(Protocol::Content::VERSION).to be =~ /^\d+\.\d+\.\d+$/ end - it "provides representations" do - expect(Protocol::Content::Representation).to be_a(Class) - expect(Protocol::Content::Parser).to be_a(Class) - end end diff --git a/test/protocol/content/default.rb b/test/protocol/content/default.rb new file mode 100644 index 0000000..cc6e77b --- /dev/null +++ b/test/protocol/content/default.rb @@ -0,0 +1,94 @@ +# 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" + + it "provides a frozen default parser" do + parser = subject.default + + expect(parser).to be(:frozen?) + expect(parser.parse("application/json", StringIO.new("{}"))).to be == {} + expect(parser.parse("application/x-www-form-urlencoded", StringIO.new("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 + + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + expect(subject.default.parse(media_type, StringIO.new(body))).to be == {"name" => "Samuel"} + end + + it "streams multipart uploads through the caller's block" do + body = <<~MULTIPART.gsub("\n", "\r\n") + --#{BOUNDARY} + Content-Disposition: form-data; name="file"; filename="hello.txt" + Content-Type: text/plain + + hello + --#{BOUNDARY}-- + MULTIPART + + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + value = subject.default.parse(media_type, StringIO.new(body)) do |_name, upload| + upload.each.to_a.join + end + + expect(value).to be == {"file" => "hello"} + end + + it "translates URL form limits" do + body = StringIO.new((["a=1"] * 1025).join("&")) + + expect do + subject.default.parse("application/x-www-form-urlencoded", body) + end.to raise_exception(Protocol::Content::ContentTooLargeError) do |error| + expect(error.cause).to be_a(Protocol::URL::LimitError) + end + end + + it "translates multipart limits" do + parts = 129.times.map do |index| + <<~PART + --#{BOUNDARY} + Content-Disposition: form-data; name="field#{index}" + + value + PART + end + body = (parts.join + "--#{BOUNDARY}--\n").gsub("\n", "\r\n") + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + expect do + subject.default.parse(media_type, StringIO.new(body)) + end.to raise_exception(Protocol::Content::ContentTooLargeError) do |error| + expect(error.cause).to be_a(Protocol::Multipart::LimitError) + end + end + + it "preserves errors raised by the caller's block" do + expect do + subject.default.parse("application/x-www-form-urlencoded", StringIO.new("a=1")) do + raise RangeError, "Application range error!" + end + end.to raise_exception(RangeError, message: be == "Application range error!") + end + + it "requires a multipart boundary" do + expect do + subject.default.parse("multipart/form-data", StringIO.new) + end.to raise_exception(ArgumentError, message: be =~ /missing a boundary/) + end +end diff --git a/test/protocol/content/json_parser.rb b/test/protocol/content/json_parser.rb new file mode 100644 index 0000000..de61085 --- /dev/null +++ b/test/protocol/content/json_parser.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/content/json_parser" + +require "stringio" + +describe Protocol::Content::JSONParser do + it "parses JSON" do + parser = subject.new(symbolize_names: true) + + expect(parser.parse(StringIO.new('{"name":"Samuel"}'))).to be == {name: "Samuel"} + end + + it "applies the encoded document size limit at its boundary" do + parser = subject.new(size_limit: 4) + + expect(parser.parse(StringIO.new("[0]"))).to be == [0] + expect(parser.parse(StringIO.new("null"))).to be_nil + + expect do + parser.parse(StringIO.new("[1,2]")) + end.to raise_exception(Protocol::Content::ContentTooLargeError, message: be =~ /exceeded limit of 4/) + end + + it "rejects a valid document followed by content beyond the size limit" do + parser = subject.new(size_limit: 4) + + expect do + parser.parse(StringIO.new("null!")) + end.to raise_exception(Protocol::Content::ContentTooLargeError, 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(StringIO.new("[1,2]"))).to be == [1, 2] + end + + it "applies the document nesting depth limit at its boundary" do + parser = subject.new(depth_limit: 2) + + expect(parser.parse(StringIO.new("[0]"))).to be == [0] + expect(parser.parse(StringIO.new("[[0]]"))).to be == [[0]] + + expect do + parser.parse(StringIO.new("[[[0]]]")) + end.to raise_exception(Protocol::Content::ContentTooLargeError) do |error| + expect(error.cause).to be_a(JSON::NestingError) + end + end + + it "limits document nesting depth by default" do + json = ("[" * 33) + "null" + ("]" * 33) + + expect do + subject.new.parse(StringIO.new(json)) + end.to raise_exception(Protocol::Content::ContentTooLargeError) + end + + it "allows the depth limit to be disabled" do + parser = subject.new(depth_limit: nil) + json = ("[" * 101) + "null" + ("]" * 101) + + expect(parser.parse(StringIO.new(json))).to be_a(Array) + end +end diff --git a/test/protocol/content/parser.rb b/test/protocol/content/parser.rb index d246c4a..b5d5839 100644 --- a/test/protocol/content/parser.rb +++ b/test/protocol/content/parser.rb @@ -33,4 +33,51 @@ parser.register("text/plain"){} end.to raise_exception(FrozenError) end + + it "parses content using a compatible handler" do + media_type = nil + input = Object.new + parser = subject.build do |parser| + parser.register("text/*") do |candidate, parsed_media_type| + media_type = parsed_media_type + candidate + end + end + + expect(parser.parse("text/plain; charset=utf-8", input)).to be_equal(input) + expect(media_type.name).to be == "text/plain" + expect(media_type.parameters).to be == {"charset" => "utf-8"} + end + + it "forwards a block to the content handler" do + parser = subject.build do |parser| + parser.register("text/plain") do |input, _media_type, &block| + block.call(input) + end + end + + value = parser.parse("text/plain", "hello") do |input| + input.upcase + end + + expect(value).to be == "HELLO" + end + + it "rejects unsupported media types" do + parser = subject.new + + expect do + parser.parse("text/plain", Object.new) + end.to raise_exception(Protocol::Content::UnsupportedMediaTypeError) do |error| + expect(error.media_type.name).to be == "text/plain" + end + end + + it "rejects a missing media type" do + parser = subject.new + + expect do + parser.parse(nil, Object.new) + end.to raise_exception(Protocol::Content::UnsupportedMediaTypeError, message: be == "Missing media type!") + end end diff --git a/test/protocol/content/representation.rb b/test/protocol/content/representation.rb deleted file mode 100644 index 3693cc7..0000000 --- a/test/protocol/content/representation.rb +++ /dev/null @@ -1,143 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "protocol/content" - -require "protocol/http/request" -require "protocol/http/response" - -require "json" - -describe Protocol::Content::Representation do - let(:parser) do - Protocol::Content::Parser.build do |parser| - parser.register("application/json") do |representation| - JSON.parse(representation.body.join) - end - end - end - - let(:representation_class) {subject[parser]} - - it "constructs a specialization with a parser" do - expect(representation_class.parser).to be_equal(parser) - end - - it "constructs from explicit representation attributes" do - metadata = {"content-type" => "application/json"} - body = [] - content_type = Protocol::Media::Type.for("application/json") - representation = subject.new(metadata, body, content_type: content_type, parser: parser) - - expect(representation.metadata).to be_equal(metadata) - expect(representation.body).to be_equal(body) - expect(representation.content_type.name).to be == "application/json" - expect(representation.parser).to be_equal(parser) - end - - it "parses an inbound request representation" do - request = Protocol::HTTP::Request["QUERY", "/users", {"content-type" => "application/json"}, ['{"user":{"name":"Samuel"}}']] - representation = representation_class.for(request) - - expect(representation.metadata).to be_equal(request.headers) - expect(representation.body).to be_equal(request.body) - expect(representation["user"]["name"]).to be == "Samuel" - end - - it "parses an inbound response representation" do - response = Protocol::HTTP::Response[200, {"content-type" => "application/json"}, ['{"status":"okay"}']] - representation = representation_class.for(response) - - expect(representation.metadata).to be_equal(response.headers) - expect(representation.body).to be_equal(response.body) - expect(representation["status"]).to be == "okay" - end - - it "parses the value once" do - count = 0 - parser = Protocol::Content::Parser.build do |parser| - parser.register("text/plain") do |representation| - count += 1 - representation.body.join - end - end - - response = Protocol::HTTP::Response[200, {"content-type" => "text/plain"}, ["Hello World"]] - representation = subject.for(response, parser: parser) - - expect(representation.value).to be == "Hello World" - expect(representation.value).to be == "Hello World" - expect(count).to be == 1 - end - - it "memoizes nil values" do - count = 0 - parser = Protocol::Content::Parser.build do |parser| - parser.register("application/x-empty") do - count += 1 - nil - end - end - - metadata = {"content-type" => "application/x-empty"} - content_type = Protocol::Media::Type.for(metadata["content-type"]) - representation = subject.new(metadata, nil, content_type: content_type, parser: parser) - - expect(representation.value).to be_nil - expect(representation.value).to be_nil - expect(count).to be == 1 - end - - it "parses content type parameters" do - response = Protocol::HTTP::Response[200, {"content-type" => "application/json; charset=utf-8"}, ["{}"]] - representation = representation_class.for(response) - - expect(representation.content_type.name).to be == "application/json" - expect(representation.content_type.parameters).to be == {"charset" => "utf-8"} - expect(representation.value).to be == {} - end - - it "extracts the content type when adapting a message" do - metadata = {"content-type" => "application/json"} - message = Struct.new(:headers, :body).new(metadata, nil) - representation = subject.for(message) - metadata["content-type"] = "text/plain" - - expect(representation.content_type.name).to be == "application/json" - end - - it "supports compatible media ranges" do - parser = Protocol::Content::Parser.build do |parser| - parser.register("text/*") do |representation| - representation.body.join - end - end - - response = Protocol::HTTP::Response[200, {"content-type" => "text/plain"}, ["Hello World"]] - representation = subject.for(response, parser: parser) - - expect(representation.value).to be == "Hello World" - end - - it "rejects unsupported media types" do - response = Protocol::HTTP::Response[200, {"content-type" => "text/plain"}, ["Hello World"]] - representation = representation_class.for(response) - - expect do - representation.value - end.to raise_exception(Protocol::Content::UnsupportedMediaTypeError) do |error| - expect(error.media_type.name).to be == "text/plain" - end - end - - it "rejects a missing content type" do - response = Protocol::HTTP::Response[200, {}, ["Hello World"]] - representation = representation_class.for(response) - - expect do - representation.value - end.to raise_exception(Protocol::Content::UnsupportedMediaTypeError, message: be == "Missing content type!") - end -end