diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 349cadd1..7c012e83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: - "3.4" - "3.3" - "3.2" - - "3.1" rack: - "~> 2.0" - "~> 3.0" diff --git a/lib/hanami/action.rb b/lib/hanami/action.rb index a73e2bce..c1868a6a 100644 --- a/lib/hanami/action.rb +++ b/lib/hanami/action.rb @@ -272,6 +272,15 @@ def self.prepend_after(...) # @since 2.0.0 # @api public def self.format(...) + msg = <<~TEXT + Hanami::Action `format` is deprecated and will be removed in Hanami 2.4. + + Please use `config.formats.accept` instead. + + See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details. + TEXT + warn(msg, category: :deprecated) + config.format(...) end @@ -326,7 +335,7 @@ def call(env) session_enabled: session_enabled? ) - enforce_accepted_mime_types(request) + enforce_accepted_media_types(request) _run_before_callbacks(request, response) handle(request, response) @@ -413,7 +422,7 @@ def _requires_no_body?(res) # @since 2.0.0 # @api private - def enforce_accepted_mime_types(request) + def enforce_accepted_media_types(request) return if config.formats.empty? Mime.enforce_accept(request, config) { return halt 406 } @@ -596,7 +605,7 @@ def finish(req, res, halted) _empty_headers(res) if _requires_empty_headers?(res) _empty_body(res) if res.head? - res.set_format(Action::Mime.detect_format(res.content_type, config)) + res.set_format(Mime.format_from_media_type(res.content_type, config)) res[:params] = req.params res[:format] = res.format res diff --git a/lib/hanami/action/config.rb b/lib/hanami/action/config.rb index 87ff05ec..67fde299 100644 --- a/lib/hanami/action/config.rb +++ b/lib/hanami/action/config.rb @@ -68,7 +68,8 @@ def handle_exception(exceptions) # Sets the format (or formats) for the action. # - # To configure custom formats and MIME type mappings, call {Formats#add formats.add} first. + # To configure custom formats and MIME type mappings, call {Formats#register formats.register} + # first. # # @example # config.format :html, :json @@ -82,10 +83,20 @@ def handle_exception(exceptions) # @since 2.0.0 # @api public def format(*formats) + msg = <<~TEXT + Hanami::Action `config.format` is deprecated and will be removed in Hanami 2.4. + + Please use `config.formats.register` and/or `config.formats.accept` instead. + + See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details. + TEXT + warn(msg, category: :deprecated) + if formats.empty? self.formats.values else self.formats.values = formats + self.formats.default = formats.first end end diff --git a/lib/hanami/action/config/formats.rb b/lib/hanami/action/config/formats.rb index dfe0f7a9..f8c4d75a 100644 --- a/lib/hanami/action/config/formats.rb +++ b/lib/hanami/action/config/formats.rb @@ -11,35 +11,57 @@ class Config # @since 2.0.0 # @api private class Formats - include Dry.Equalizer(:values, :mapping) + include Dry.Equalizer(:accepted, :mapping) - # Default MIME type to format mapping - # # @since 2.0.0 # @api private - DEFAULT_MAPPING = { - "application/octet-stream" => :all, - "*/*" => :all - }.freeze + attr_reader :mapping + # The array of formats to accept requests by. + # + # @example + # config.formats.accepted = [:html, :json] + # config.formats.accepted # => [:html, :json] + # # @since 2.0.0 - # @api private - attr_reader :mapping + # @api public + attr_reader :accepted + + # @see #accepted + # + # @since 2.0.0 + # @api public + def values + msg = <<~TEXT + Hanami::Action `config.formats.values` is deprecated and will be removed in Hanami 2.4. + + Please use `config.formats.accepted` instead. + + See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details. + TEXT + warn(msg, category: :deprecated) + + accepted + end - # The array of enabled formats. + # Returns the default format name. + # + # When a request is received that cannot + # + # @return [Symbol, nil] the default format name, if any # # @example - # config.formats.values = [:html, :json] - # config.formats.values # => [:html, :json] + # @config.formats.default # => :json # # @since 2.0.0 # @api public - attr_reader :values + attr_reader :default # @since 2.0.0 # @api private - def initialize(values: [], mapping: DEFAULT_MAPPING.dup) - @values = values + def initialize(accepted: [], default: nil, mapping: {}) + @accepted = accepted + @default = default @mapping = mapping end @@ -47,15 +69,74 @@ def initialize(values: [], mapping: DEFAULT_MAPPING.dup) # @api private private def initialize_copy(original) # rubocop:disable Style/AccessModifierDeclarations super - @values = original.values.dup + @accepted = original.accepted.dup + @default = original.default @mapping = original.mapping.dup end + # !@attribute [w] accepted + # @since 2.3.0 + # @api public + def accepted=(formats) + @accepted = formats.map { |f| Hanami::Utils::Kernel.Symbol(f) } + end + # !@attribute [w] values # @since 2.0.0 # @api public - def values=(formats) - @values = formats.map { |f| Utils::Kernel.Symbol(f) } + alias_method :values=, :accepted= + + # @since 2.3.0 + def accept(*formats) + self.default = formats.first if default.nil? + self.accepted = accepted | formats + end + + # @api private + def accepted_formats(standard_formats = {}) + accepted.to_h { |format| + [ + format, + mapping.fetch(format) { standard_formats[format] } + ] + } + end + + # @since 2.3.0 + def default=(format) + @default = format.to_sym + end + + # Registers a format and its associated media types. + # + # @param format [Symbol] the format name + # @param media_type [String] the format's media type + # @param accept_types [Array] media types to accept in request `Accept` headers + # @param content_types [Array] media types to accept in request `Content-Type` headers + # + # @example + # config.formats.register(:scim, media_type: "application/json+scim") + # + # config.formats.register( + # :jsonapi, + # "application/vnd.api+json", + # accept_types: ["application/vnd.api+json", "application/json"], + # content_types: ["application/vnd.api+json", "application/json"] + # ) + # + # @return [self] + # + # @since 2.3.0 + # @api public + def register(format, media_type, accept_types: [media_type], content_types: [media_type]) + mapping[format] = Mime::Format.new( + name: format.to_sym, + media_type: media_type, + accept_types: accept_types, + content_types: content_types + ) + + self end # @overload add(format) @@ -83,20 +164,30 @@ def values=(formats) # @param mime_types [Array] # # @example - # config.formats.add(:json, ["application/json+scim", "application/json"]) + # config.formats.add(:json, ["application/json+scim"]) # # @return [self] # # @since 2.0.0 # @api public - def add(format, mime_types = []) - format = Utils::Kernel.Symbol(format) + def add(format, mime_types) + msg = <<~TEXT + Hanami::Action `config.formats.add` is deprecated and will be removed in Hanami 2.4. + + Please use `config.formats.register` instead. + + See https://guides.hanamirb.org/v2.3/actions/formats-and-mime-types/ for details. + TEXT + warn(msg, category: :deprecated) + + mime_type = Array(mime_types).first - Array(mime_types).each do |mime_type| - @mapping[Utils::Kernel.String(mime_type)] = format - end + # The old behaviour would have subsequent mime types _replacing_ previous ones + mapping.reject! { |_, format| format.media_type == mime_type } - @values << format unless @values.include?(format) + register(format, Array(mime_types).first, accept_types: mime_types) + + accept(format) unless @accepted.include?(format) self end @@ -104,31 +195,19 @@ def add(format, mime_types = []) # @since 2.0.0 # @api private def empty? - @values.empty? + accepted.empty? end # @since 2.0.0 # @api private def any? - @values.any? + @accepted.any? end # @since 2.0.0 # @api private def map(&blk) - @values.map(&blk) - end - - # @since 2.0.0 - # @api private - def mapping=(mappings) - @mapping = {} - - mappings.each do |format_name, mime_types| - Array(mime_types).each do |mime_type| - add(format_name, mime_type) - end - end + @accepted.map(&blk) end # Clears any previously added mappings and format values. @@ -138,15 +217,24 @@ def mapping=(mappings) # @since 2.0.0 # @api public def clear - @mapping = DEFAULT_MAPPING.dup - @values = [] + @accepted = [] + @default = nil + @mapping = {} self end - # Retrieve the format name associated with the given MIME Type + # Returns an array of all accepted media types. + # + # @since 2.3.0 + # @api public + def accept_types + accepted.map { |format| mapping[format]&.accept_types }.flatten(1).compact + end + + # Retrieve the format name associated with the given media type # - # @param mime_type [String] the MIME Type + # @param media_type [String] the media Type # # @return [Symbol,NilClass] the associated format name, if any # @@ -157,59 +245,46 @@ def clear # # @since 2.0.0 # @api public - def format_for(mime_type) - @mapping[mime_type] + def format_for(media_type) + mapping.values.reverse.find { |format| format.media_type == media_type }&.name end - # Returns the primary MIME type associated with the given format. + # Returns the media type associated with the given format. # # @param format [Symbol] the format name # - # @return [String, nil] the associated MIME type, if any + # @return [String, nil] the associated media type, if any # # @example - # @config.formats.mime_type_for(:json) # => "application/json" + # @config.formats.media_type_for(:json) # => "application/json" # # @see #format_for # - # @since 2.0.0 + # @since 2.3.0 # @api public - def mime_type_for(format) - @mapping.key(format) + def media_type_for(format) + mapping[format]&.media_type end - # Returns an array of all MIME types associated with the given format. - # - # Returns an empty array if no such format is configured. - # - # @param format [Symbol] the format name - # - # @return [Array] the associated MIME types - # - # @since 2.0.0 - # @api public - def mime_types_for(format) - @mapping.each_with_object([]) { |(mime_type, f), arr| arr << mime_type if format == f } + # @api private + def accept_types_for(format) + mapping[format]&.accept_types || [] end - # Returns the default format name - # - # @return [Symbol, nil] the default format name, if any - # - # @example - # @config.formats.default # => :json - # + # @api private + def content_types_for(format) + mapping[format]&.content_types || [] + end + + # @see #media_type_for # @since 2.0.0 # @api public - def default - @values.first - end + alias_method :mime_type_for, :media_type_for + # @see #media_type_for # @since 2.0.0 - # @api private - def keys - @mapping.keys - end + # @api public + alias_method :mime_types_for, :accept_types_for end end end diff --git a/lib/hanami/action/mime.rb b/lib/hanami/action/mime.rb index df6e89f4..9e9ce39b 100644 --- a/lib/hanami/action/mime.rb +++ b/lib/hanami/action/mime.rb @@ -7,39 +7,39 @@ module Hanami class Action + # @api private module Mime # rubocop:disable Metrics/ModuleLength - # Most commom MIME Types used for responses + # Most commom media types used for responses # # @since 1.0.0 # @api public TYPES = { - txt: "text/plain", - html: "text/html", - form: "application/x-www-form-urlencoded", - multipart: "multipart/form-data", - json: "application/json", - manifest: "text/cache-manifest", atom: "application/atom+xml", avi: "video/x-msvideo", bmp: "image/bmp", - bz: "application/x-bzip", bz2: "application/x-bzip2", + bz: "application/x-bzip", chm: "application/vnd.ms-htmlhelp", css: "text/css", csv: "text/csv", flv: "video/x-flv", + form: "application/x-www-form-urlencoded", gif: "image/gif", gz: "application/x-gzip", h264: "video/h264", + html: "text/html", ico: "image/vnd.microsoft.icon", ics: "text/calendar", jpg: "image/jpeg", js: "application/javascript", - mp4: "video/mp4", + json: "application/json", + manifest: "text/cache-manifest", mov: "video/quicktime", mp3: "audio/mpeg", + mp4: "video/mp4", mp4a: "audio/mp4", mpg: "video/mpeg", + multipart: "multipart/form-data", oga: "audio/ogg", ogg: "application/ogg", ogv: "video/ogg", @@ -55,13 +55,14 @@ module Mime # rubocop:disable Metrics/ModuleLength tar: "application/x-tar", torrent: "application/x-bittorrent", tsv: "text/tab-separated-values", + txt: "text/plain", uri: "text/uri-list", vcs: "text/x-vcalendar", wav: "audio/x-wav", webm: "video/webm", wmv: "video/x-ms-wmv", - woff: "application/font-woff", woff2: "application/font-woff2", + woff: "application/font-woff", wsdl: "application/wsdl+xml", xhtml: "application/xhtml+xml", xml: "application/xml", @@ -70,9 +71,109 @@ module Mime # rubocop:disable Metrics/ModuleLength zip: "application/zip" }.freeze + # @api private ANY_TYPE = "*/*" + # @api private + Format = Data.define(:name, :media_type, :accept_types, :content_types) do + def initialize(name:, media_type:, accept_types: [media_type], content_types: [media_type]) + super + end + end + + # @api private + FORMATS = TYPES + .to_h { |name, media_type| [name, Format.new(name:, media_type:)] } + .update( + all: Format.new( + name: :all, + media_type: "application/octet-stream", + accept_types: ["*/*"], + content_types: ["*/*"] + ), + html: Format.new( + name: :html, + media_type: "text/html", + content_types: ["application/x-www-form-urlencoded", "multipart/form-data"] + ) + ) + .freeze + private_constant :FORMATS + + # @api private + MEDIA_TYPES_TO_FORMATS = FORMATS.each_with_object({}) { |(_name, format), hsh| + hsh[format.media_type] = format + }.freeze + private_constant :MEDIA_TYPES_TO_FORMATS + + # @api private + ACCEPT_TYPES_TO_FORMATS = FORMATS.each_with_object({}) { |(_name, format), hsh| + format.accept_types.each { |type| hsh[type] = format } + }.freeze + private_constant :ACCEPT_TYPES_TO_FORMATS + class << self + # Yields if an action is configured with `formats`, the request has an `Accept` header, and + # none of the Accept types matches the accepted formats. The given block is expected to halt + # the request handling. + # + # If any of these conditions are not met, then the request is acceptable and the method + # returns without yielding. + # + # @see Action#enforce_accepted_media_types + # @see Config#formats + # + # @api private + def enforce_accept(request, config) + return unless request.accept_header? + + accept_types = ::Rack::Utils.q_values(request.accept).map(&:first) + return if accept_types.any? { |type| accepted_type?(type, config) } + + yield + end + + # Yields if an action is configured with `formats`, the request has a `Content-Type` header, + # and the content type does not match the accepted formats. The given block is expected to + # halt the request handling. + # + # If any of these conditions are not met, then the request is acceptable and the method + # returns without yielding. + # + # @see Action#enforce_accepted_media_types + # @see Config#formats + # + # @api private + def enforce_content_type(request, config) + # Compare media type (without parameters) instead of full Content-Type header to avoid + # false negatives (e.g., multipart/form-data; boundary=...) + media_type = request.media_type + + return if media_type.nil? + + return if accepted_content_type?(media_type, config) + + yield + end + + # Returns a string combining a media type and charset, intended for setting as the + # `Content-Type` header for the response to the given request. + # + # This uses the request's `Accept` header (if present) along with the configured formats to + # determine the best content type to return. + # + # @return [String] + # + # @see Action#call + # + # @api private + def response_content_type_with_charset(request, config) + content_type_with_charset( + response_content_type(request, config), + config.default_charset || Action::DEFAULT_CHARSET + ) + end + # Returns a format name for the given content type. # # The format name will come from the configured formats, if such a format is configured @@ -83,52 +184,50 @@ class << self # This is used to return the format name a {Response}. # # @example - # detect_format("application/jsonl charset=utf-8", config) # => :json + # format_from_media_type("application/json;charset=utf-8", config) # => :json # # @return [Symbol, nil] # # @see Response#format # @see Action#finish # - # @since 2.0.0 # @api private - def detect_format(content_type, config) - return if content_type.nil? + def format_from_media_type(media_type, config) + return if media_type.nil? - ct = content_type.split(";").first - config.formats.format_for(ct) || TYPES.key(ct) + mt = media_type.split(";").first + config.formats.format_for(mt) || MEDIA_TYPES_TO_FORMATS[mt]&.name end # Returns a format name and content type pair for a given format name or content type # string. # # @example - # detect_format_and_content_type(:json, config) + # format_and_media_type(:json, config) # # => [:json, "application/json"] # - # detect_format_and_content_type("application/json", config) + # format_and_media_type("application/json", config) # # => [:json, "application/json"] # # @example Unknown format name - # detect_format_and_content_type(:unknown, config) + # format_and_media_type(:unknown, config) # # raises Hanami::Action::UnknownFormatError # # @example Unknown content type - # detect_format_and_content_type("application/unknown", config) + # format_and_media_type("application/unknown", config) # # => [nil, "application/unknown"] # # @return [Array<(Symbol, String)>] # # @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given # - # @since 2.0.0 # @api private - def detect_format_and_content_type(value, config) + def format_and_media_type(value, config) case value when Symbol - [value, format_to_mime_type(value, config)] + [value, format_to_media_type(value, config)] when String - [detect_format(value, config), value] + [format_from_media_type(value, config), value] else raise UnknownFormatError.new(value) end @@ -146,34 +245,13 @@ def detect_format_and_content_type(value, config) # # @return [String] # - # @since 2.0.0 # @api private def content_type_with_charset(content_type, charset) "#{content_type}; charset=#{charset}" end - # Returns a string combining a MIME type and charset, intended for setting as the - # `Content-Type` header for the response to the given request. - # - # This uses the request's `Accept` header (if present) along with the configured formats to - # determine the best content type to return. - # - # @return [String] - # - # @see Action#call - # - # @since 2.0.0 - # @api private - def response_content_type_with_charset(request, config) - content_type_with_charset( - response_content_type(request, config), - config.default_charset || Action::DEFAULT_CHARSET - ) - end - # Patched version of Rack::Utils.best_q_match. # - # @since 2.0.0 # @api private # # @see http://www.rubydoc.info/gems/rack/Rack/Utils#best_q_match-class_method @@ -181,7 +259,7 @@ def response_content_type_with_charset(request, config) # @see https://github.com/hanami/controller/issues/59 # @see https://github.com/hanami/controller/issues/104 # @see https://github.com/hanami/controller/issues/275 - def best_q_match(q_value_header, available_mimes = TYPES.values) + def best_q_match(q_value_header, available_mimes) ::Rack::Utils.q_values(q_value_header).each_with_index.map { |(req_mime, quality), index| match = available_mimes.find { |am| ::Rack::Mime.match?(am, req_mime) } next unless match @@ -190,99 +268,118 @@ def best_q_match(q_value_header, available_mimes = TYPES.values) }.compact.max&.format end - # Yields if an action is configured with `formats`, the request has an `Accept` header, an - # none of the Accept types matches the accepted formats. The given block is expected to halt - # the request handling. - # - # If any of these conditions are not met, then the request is acceptable and the method - # returns without yielding. - # - # @see Action#enforce_accepted_mime_types - # @see Config#formats - # - # @since 2.0.0 - # @api private - def enforce_accept(request, config) - return unless request.accept_header? - - accept_types = ::Rack::Utils.q_values(request.accept).map(&:first) - return if accept_types.any? { |mime_type| accepted_mime_type?(mime_type, config) } + private - yield + # @api private + def accepted_type?(media_type, config) + accepted_types(config).any? { |accepted_type| + ::Rack::Mime.match?(media_type, accepted_type) + } end - # Yields if an action is configured with `formats`, the request has a `Content-Type` header - # (or a `default_requst_format` is configured), and the content type does not match the - # accepted formats. The given block is expected to halt the request handling. - # - # If any of these conditions are not met, then the request is acceptable and the method - # returns without yielding. - # - # @see Action#enforce_accepted_mime_types - # @see Config#formats - # - # @since 2.0.0 # @api private - def enforce_content_type(request, config) - # Compare media type (without parameters) instead of full Content-Type header - # to avoid false negatives (e.g., multipart/form-data; boundary=...) - media_type = request.media_type + def accepted_types(config) + return [ANY_TYPE] if config.formats.empty? - return if media_type.nil? + config.formats.map { |format| format_to_accept_types(format, config) }.flatten(1) + end - return if accepted_mime_type?(media_type, config) + def format_to_accept_types(format, config) + configured_types = config.formats.accept_types_for(format) + return configured_types if configured_types.any? - yield + FORMATS + .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) } + .accept_types end - private - - # @since 2.0.0 # @api private - def accepted_mime_type?(mime_type, config) - accepted_mime_types(config).any? { |accepted_mime_type| - ::Rack::Mime.match?(mime_type, accepted_mime_type) + def accepted_content_type?(content_type, config) + accepted_content_types(config).any? { |accepted_content_type| + ::Rack::Mime.match?(content_type, accepted_content_type) } end - # @since 2.0.0 # @api private - def accepted_mime_types(config) + def accepted_content_types(config) return [ANY_TYPE] if config.formats.empty? - config.formats.map { |format| format_to_mime_types(format, config) }.flatten(1) + config.formats.map { |format| format_to_content_types(format, config) }.flatten(1) + end + + # @api private + def format_to_content_types(format, config) + configured_types = config.formats.content_types_for(format) + return configured_types if configured_types.any? + + FORMATS + .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) } + .content_types end - # @since 2.0.0 # @api private def response_content_type(request, config) + # This method prepares the default `Content-Type` for the response. Importantly, it only + # does this after `#enforce_accept` and `#enforce_content_type` have already passed the + # request. So by the time we get here, the request has been deemed acceptable to the + # action, so we can try to be as helpful as possible in setting an appropriate content + # type for the response. + if request.accept_header? - all_mime_types = TYPES.values + config.formats.mapping.keys - content_type = best_q_match(request.accept, all_mime_types) + content_type = + if config.formats.empty? || config.formats.accepted.include?(:all) + permissive_response_content_type(request, config) + else + restrictive_response_content_type(request, config) + end return content_type if content_type end if config.formats.default - return format_to_mime_type(config.formats.default, config) + return format_to_media_type(config.formats.default, config) end Action::DEFAULT_CONTENT_TYPE end - # @since 2.0.0 # @api private - def format_to_mime_type(format, config) - config.formats.mime_type_for(format) || - TYPES.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) } + def permissive_response_content_type(request, config) + # If no accepted formats are configured, or if the formats include :all, then we're + # working with a "permissive" action. In this case we simply want a response content type + # that corresponds to the request's accept header as closely as possible. This means we + # work from _all_ the media types we know of. + + all_media_types = + (ACCEPT_TYPES_TO_FORMATS.keys | MEDIA_TYPES_TO_FORMATS.keys) + + config.formats.accept_types + + best_q_match(request.accept, all_media_types) end - # @since 2.0.0 # @api private - def format_to_mime_types(format, config) - config.formats.mime_types_for(format).tap { |types| - types << TYPES[format] if TYPES.key?(format) - } + def restrictive_response_content_type(request, config) + # When specific formats are configured, this is a "resitrctive" action. Here we want to + # match against the configured accept types only, and work back from those to the + # configured format, so we can use its canonical media type for the content type. + + accept_types_to_formats = config.formats.accepted_formats(FORMATS) + .each_with_object({}) { |(_, format), hsh| + format.accept_types.each { hsh[_1] = format } + } + + accept_type = best_q_match(request.accept, accept_types_to_formats.keys) + accept_types_to_formats[accept_type].media_type if accept_type + end + + # @api private + def format_to_media_type(format, config) + configured_type = config.formats.media_type_for(format) + return configured_type if configured_type + + FORMATS + .fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) } + .media_type end end end diff --git a/lib/hanami/action/response.rb b/lib/hanami/action/response.rb index b9f504b5..0355a105 100644 --- a/lib/hanami/action/response.rb +++ b/lib/hanami/action/response.rb @@ -42,7 +42,7 @@ def self.build(status, env) new(config: Action.config.dup, content_type: Mime.best_q_match(env[Action::HTTP_ACCEPT]), env: env).tap do |r| r.status = status r.body = Http::Status.message_for(status) - r.set_format(Mime.detect_format(r.content_type), config) + r.set_format(Mime.format_from_media_type(r.content_type), config) end end @@ -134,7 +134,7 @@ def render(view, **input) # @since 2.0.0 # @api public def format - @format ||= Mime.detect_format(content_type, @config) + @format ||= Mime.format_from_media_type(content_type, @config) end # Sets the format and associated content type for the response. @@ -165,7 +165,7 @@ def format # @since 2.0.0 # @api public def format=(value) - format, content_type = Mime.detect_format_and_content_type(value, @config) + format, content_type = Mime.format_and_media_type(value, @config) self.content_type = Mime.content_type_with_charset(content_type, charset) diff --git a/spec/integration/hanami/controller/mime_type_spec.rb b/spec/integration/hanami/controller/mime_type_spec.rb index 9d0ef213..283d6d22 100644 --- a/spec/integration/hanami/controller/mime_type_spec.rb +++ b/spec/integration/hanami/controller/mime_type_spec.rb @@ -81,6 +81,12 @@ expect(response.body).to eq("custom") end + it %(sets the "Content-Type" header to the format's canonical media type) do + response = app.get("/custom_from_accept", "HTTP_ACCEPT" => "application/custom+variant") + expect(response.headers["Content-Type"]).to eq("application/custom; charset=utf-8") + expect(response.body).to eq("custom") + end + it 'sets "Content-Type" header according to weighted value' do response = app.get("/custom_from_accept", "HTTP_ACCEPT" => "application/custom;q=0.9,application/json;q=0.5") expect(response.headers["Content-Type"]).to eq("application/custom; charset=utf-8") diff --git a/spec/support/fixtures.rb b/spec/support/fixtures.rb index 6afd99ef..8cd3571d 100644 --- a/spec/support/fixtures.rb +++ b/spec/support/fixtures.rb @@ -1597,9 +1597,8 @@ def handle(req, res) end class CustomFromAccept < Hanami::Action - config.formats.add :custom, "application/custom" - - format :json, :custom + config.formats.register :custom, "application/custom", accept_types: ["application/custom", "application/custom+variant"] + config.formats.accept :json, :custom def handle(*, res) res.body = res.format @@ -1607,9 +1606,6 @@ def handle(*, res) end class UploadAction < Hanami::Action - config.formats.add :multipart, "multipart/form-data" - config.format :multipart - def handle(req, res) res.format = :txt res.body = req.content_type @@ -1617,9 +1613,8 @@ def handle(req, res) end class Restricted < Hanami::Action - config.formats.add :custom, "application/custom" - - format :html, :json, :custom + config.formats.register :custom, "application/custom" + config.formats.accept :html, :json, :custom def handle(_req, res) res.body = res.format @@ -1633,7 +1628,7 @@ def handle(_req, res) end class Strict < Hanami::Action - format :json + config.formats.accept :json def handle(_req, res) res.body = res.format @@ -1641,7 +1636,7 @@ def handle(_req, res) end class Relaxed < Hanami::Action - format :all + config.formats.accept :all def handle(_req, res) res.body = res.format @@ -1672,7 +1667,7 @@ def call(env) module MimesWithDefault class Default < Hanami::Action - config.format :html, :json + config.formats.accept :html, :json def handle(*, res) res.body = res.format diff --git a/spec/unit/hanami/action/config/formats_spec.rb b/spec/unit/hanami/action/config/formats_spec.rb index 8697ee20..c052679e 100644 --- a/spec/unit/hanami/action/config/formats_spec.rb +++ b/spec/unit/hanami/action/config/formats_spec.rb @@ -3,76 +3,96 @@ RSpec.describe Hanami::Action::Config::Formats do subject(:formats) { described_class.new } - describe "#mapping" do - it "is a basic mapping of mime types to `:all` formats by default" do - expect(formats.mapping).to eq( - "application/octet-stream" => :all, - "*/*" => :all - ) + describe "#register" do + it "registers a mapping" do + expect { formats.register(:custom, "application/custom") } + .to change { formats.mapping } + .to include(custom: have_attributes(media_type: "application/custom")) end - it "can be replaced a mapping" do - expect { formats.mapping = {all: "*/*"} } + it "registers a mapping with content types" do + expect { + formats.register( + :jsonapi, + "application/vnd.api+json", + accept_types: ["application/vnd.api+json", "application/json"], + content_types: ["application/vnd.api+json", "application/json"] + ) + } .to change { formats.mapping } - .to("*/*" => :all) + .to include( + jsonapi: have_attributes( + media_type: "application/vnd.api+json", + accept_types: ["application/vnd.api+json", "application/json"], + content_types: ["application/vnd.api+json", "application/json"] + ) + ) end end - describe "#add" do - it "adds a new mapping" do - expect { formats.add(:custom, "application/custom") } - .to change { formats.mapping } - .to include("application/custom" => :custom) + describe "#accepted" do + it "returns an empty array by default" do + expect(formats.accepted).to eq [] end - it "can add a mapping to multiple content types" do - expect { formats.add(:json, ["application/json", "application/json+scim"]) } - .to change { formats.mapping } - .to include("application/json" => :json, "application/json+scim" => :json) + it "returns the formats configured by #accept" do + expect { formats.accept :json } + .to change { formats.accepted } + .to [:json] end - it "replaces the a previously set mapping for a given MIME type" do - formats.mapping = {html: "text/html"} - formats.add :custom, "text/html" + it "can be assigned with an array of formats" do + expect { formats.accepted = [:json, :html] } + .to change { formats.accepted } + .to [:json, :html] + end + end - expect(formats.mapping).to eq("text/html" => :custom) + describe "#accept" do + it "sets the list of accepted formats" do + expect { formats.accept :json, :html } + .to change { formats.accepted } + .to [:json, :html] end - it "appends the format to the list of enabled formats" do - formats.values = [:json] + it "appends to the list of accepted formats when called more than once" do + expect { formats.accept :json } + .to change { formats.accepted } + .to([:json]) - expect { - formats.add(:custom, "application/custom") - formats.add(:custom, "application/custom+more") - } - .to change { formats.values } - .to [:json, :custom] + expect { formats.accept :html } + .to change { formats.accepted } + .to([:json, :html]) + + expect { formats.accept :json, :custom } + .to change { formats.accepted } + .to [:json, :html, :custom] end - it "raises an error if the given format cannot be coerced into symbol" do - expect { formats.add(23, "boom") }.to raise_error(TypeError) + it "sets the default format to the first format, when no default is set" do + expect { formats.accept :json } + .to change { formats.default } + .to :json end - it "raises an error if the given mime type cannot be coerced into string" do - obj = Class.new(BasicObject) do - def hash - 23 - end - end.new + it "does not change the default format when it has already been set" do + formats.default = :html - expect { formats.add(:boom, obj) }.to raise_error(TypeError) + expect { formats.accept :json } + .not_to change { formats.default } + .from :html end end - describe "#values" do - it "returns an empty array by default" do - expect(formats.values).to eq [] + describe "#default" do + it "returns nil by default" do + expect(formats.default).to be nil end - it "can have a list of format names assigned" do - expect { formats.values = [:json, :html] } - .to change { formats.values } - .to [:json, :html] + it "can be assigned to a format" do + expect { formats.default = :json } + .to change { formats.default } + .to :json end end @@ -90,7 +110,7 @@ def hash describe "#format_for" do before do - formats.mapping = {html: "text/html"} + formats.register(:html, "text/html") end it "returns the configured format for the given MIME type" do @@ -98,7 +118,7 @@ def hash end it "returns the most recently configured format for a given MIME type" do - formats.add :htm, "text/html" + formats.register :htm, "text/html" expect(formats.format_for("text/html")).to eq(:htm) end @@ -108,31 +128,52 @@ def hash end end - describe "#mime_type_for" do + describe "#media_type_for" do before do - formats.mapping = {html: ["text/html", "text/htm"]} + formats.register(:custom, "application/custom") end - it "returns the first configured MIME type for the given format" do - expect(formats.mime_type_for(:html)).to eq "text/html" + it "returns the configured media type for the given format" do + expect(formats.media_type_for(:custom)).to eq "application/custom" end - it "returns nil if no matching MIME type is found" do + it "returns nil if no matching format is found" do expect(formats.mime_type_for(:missing)).to be nil end end - describe "#mime_types_for" do - before do - formats.mapping = {html: ["text/html", "text/htm"]} - end - - it "returns all configured MIME types for the given format" do - expect(formats.mime_types_for(:html)).to eq ["text/html", "text/htm"] - end - - it "returns an empty array if no matching MIME type is found" do - expect(formats.mime_types_for(:missing)).to eq [] + describe "deprecated behavior" do + describe "#add" do + it "adds a new mapping" do + expect { formats.add(:custom, "application/custom") } + .to change { formats.mapping } + .to include(custom: have_attributes(media_type: "application/custom")) + end + + it "can add a mapping to multiple content types" do + expect { formats.add(:json, ["application/json", "application/json+scim"]) } + .to change { formats.mapping } + .to include(json: have_attributes(media_type: "application/json", accept_types: ["application/json", "application/json+scim"])) + end + + it "replaces a previously set mapping for a given MIME type" do + formats.register(:html, "text/html") + formats.add :custom, "text/html" + + expect(formats.mapping).to match(custom: have_attributes(media_type: "text/html")) + end + end + + describe "#values" do + it "returns an empty array by default" do + expect(formats.values).to eq [] + end + + it "can have a list of format names assigned" do + expect { formats.values = [:json, :html] } + .to change { formats.values } + .to [:json, :html] + end end end end diff --git a/spec/unit/hanami/action/config_spec.rb b/spec/unit/hanami/action/config_spec.rb index 267df534..432b14ac 100644 --- a/spec/unit/hanami/action/config_spec.rb +++ b/spec/unit/hanami/action/config_spec.rb @@ -24,12 +24,12 @@ describe "#format" do it "sets formats" do - config.format :json, :html + config.formats.accept :json, :html expect(config.formats.values).to eq [:json, :html] end it "returns previously set formats" do - config.format :json, :html + config.formats.accept :json, :html expect(config.format).to eq [:json, :html] end end diff --git a/spec/unit/hanami/action/format_spec.rb b/spec/unit/hanami/action/format_spec.rb index 0194af16..99bde49f 100644 --- a/spec/unit/hanami/action/format_spec.rb +++ b/spec/unit/hanami/action/format_spec.rb @@ -17,7 +17,7 @@ def handle(req, res) end class Configuration < Hanami::Action - config.format :jpg + config.formats.accept :jpg def handle(*, res) res.body = res.format