diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb new file mode 100644 index 0000000..414f87f --- /dev/null +++ b/lib/protocol/http/header/accept.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2023, by Samuel Williams. +# Copyright, 2023, by Thomas Morgan. + +require_relative "split" +require_relative "quoted_string" +require_relative "../error" + +module Protocol + module HTTP + module Header + # The `accept-content-type` header represents a list of content-types that the client can accept. + class Accept < Array + # Regular expression used to split values on commas, with optional surrounding whitespace, taking into account quoted strings. + SPLIT = / + (?: # Start non-capturing group + "[^"\\]*" # Match quoted strings (no escaping of quotes within) + | # OR + [^,"]+ # Match non-quoted strings until a comma or quote + )+ + (?=,|\z) # Match until a comma or end of string + /x + + ParseError = Class.new(Error) + + MEDIA_RANGE = /\A(?#{TOKEN})\/(?#{TOKEN})(?.*)\z/ + + PARAMETER = /\s*;\s*(?#{TOKEN})=((?#{TOKEN})|(?#{QUOTED_STRING}))/ + + # A single entry in the Accept: header, which includes a mime type and associated parameters. + MediaRange = Struct.new(:type, :subtype, :parameters) do + def initialize(type, subtype = '*', parameters = {}) + super(type, subtype, parameters) + end + + def <=> other + other.quality_factor <=> self.quality_factor + end + + def parameters_string + return '' if parameters == nil or parameters.empty? + + parameters.collect do |key, value| + "; #{key.to_s}=#{QuotedString.quote(value.to_s)}" + end.join + end + + def === other + if other.is_a? self.class + super + else + return self.mime_type === other + end + end + + def mime_type + "#{type}/#{subtype}" + end + + def to_s + "#{type}/#{subtype}#{parameters_string}" + end + + alias to_str to_s + + def quality_factor + parameters.fetch('q', 1.0).to_f + end + + def split(*args) + return [type, subtype] + end + end + + def initialize(value = nil) + if value + super(value.scan(SPLIT).map(&:strip)) + else + end + end + + # Adds one or more comma-separated values to the header. + # + # The input string is split into distinct entries and appended to the array. + # + # @parameter value [String] the value or values to add, separated by commas. + def << (value) + self.concat(value.scan(SPLIT).map(&:strip)) + end + + # Serializes the stored values into a comma-separated string. + # + # @returns [String] the serialized representation of the header values. + def to_s + join(",") + end + + # Parse the `accept` header. + # + # @returns [Array(Charset)] the list of content types and their associated parameters. + def media_ranges + self.map do |value| + self.parse_media_range(value) + end + end + + private + + def parse_media_range(value) + if match = value.match(MEDIA_RANGE) + type = match[:type] + subtype = match[:subtype] + parameters = {} + + match[:parameters].scan(PARAMETER) do |key, value, quoted_value| + parameters[key] = quoted_value || value + end + + return MediaRange.new(type, subtype, parameters) + else + raise ArgumentError, "Invalid media type: #{value.inspect}" + end + end + end + end + end +end diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 5742e39..bae23ff 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -30,7 +30,7 @@ def initialize(value = nil) # # @parameter value [String] the value or values to add, separated by commas. def << value - self.push(*value.split(COMMA)) + self.concat(value.split(COMMA)) end # Serializes the stored values into a comma-separated string. diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index b96e3b2..3f810e0 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -3,11 +3,9 @@ # Released under the MIT License. # Copyright, 2018-2024, by Samuel Williams. -require_relative "header/accept_charset" -require_relative "header/accept_encoding" -require_relative "header/accept_language" require_relative "header/split" require_relative "header/multiple" + require_relative "header/cookie" require_relative "header/connection" require_relative "header/cache_control" @@ -18,6 +16,11 @@ require_relative "header/date" require_relative "header/priority" +require_relative "header/accept" +require_relative "header/accept_charset" +require_relative "header/accept_encoding" +require_relative "header/accept_language" + module Protocol module HTTP # @namespace @@ -281,6 +284,8 @@ def []= key, value "if-modified-since" => Header::Date, "if-unmodified-since" => Header::Date, + # Accept headers: + "accept" => Header::Accept, "accept-charset" => Header::AcceptCharset, "accept-encoding" => Header::AcceptEncoding, "accept-language" => Header::AcceptLanguage, diff --git a/test/protocol/http/header/accept.rb b/test/protocol/http/header/accept.rb new file mode 100644 index 0000000..9e3c000 --- /dev/null +++ b/test/protocol/http/header/accept.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2016, by Matthew Kerwin. +# Copyright, 2017-2024, by Samuel Williams. + +require 'protocol/http/header/accept' + +describe Protocol::HTTP::Header::Accept::MediaRange do + it "should have default quality_factor of 1.0" do + language = subject.new('text/plain', nil) + expect(language.quality_factor).to be == 1.0 + end +end + +describe Protocol::HTTP::Header::Accept do + let(:header) {subject.new(description)} + let(:media_ranges) {header.media_ranges.sort} + + with "text/plain, text/html;q=0.5, text/xml;q=0.25" do + it "can parse media ranges" do + expect(header.length).to be == 3 + + expect(media_ranges[0].mime_type).to be == "text/plain" + expect(media_ranges[0].quality_factor).to be == 1.0 + + expect(media_ranges[1].mime_type).to be == "text/html" + expect(media_ranges[1].quality_factor).to be == 0.5 + + expect(media_ranges[2].mime_type).to be == "text/xml" + expect(media_ranges[2].quality_factor).to be == 0.25 + end + end + + with "text/html;q=0.25, text/xml;q=0.5, text/plain" do + it "should order based on quality factor" do + expect(media_ranges.collect(&:mime_type)).to be == %w{text/plain text/xml text/html} + end + end + + with "text/html, text/plain;q=0.8, text/xml;q=0.6, application/json" do + it "should order based on quality factor" do + expect(media_ranges.collect(&:mime_type)).to be == %w{text/html application/json text/plain text/xml} + end + end + + with "*/*;q=0" do + it "should accept wildcard media range" do + expect(media_ranges[0].mime_type).to be == "*/*" + expect(media_ranges[0].quality_factor).to be == 0 + end + end + + +end