Skip to content

Negotiation

As mentioned in the view event documentation; this event is emitted whenever a controller action does NOT return an ART::Response, with this value being JSON serialized by default. The Negotiation component enhances the view layer of Athena by enabling content negotiation support; making it possible to write format agnostic controllers by placing a layer of abstraction between the controller and generation of the final response content. Or in other words allow having the same controller action be rendered based on the request's Accept HTTP header and the format priority configuration.

Configuration#

See the config component documentation for an overview on how configuration is handled in Athena.

Negotiation#

The content negotiation logic is disabled by default, but can be easily enabled by redefining ART::Config::ContentNegotiation.configure with the desired configuration. Content negotiation configuration is represented by an array of Rules used to describe allowed formats, their priorities, and how things should function if a unsupported format is requested.

For example, say we configured things like:

def ART::Config::ContentNegotiation.configure
  new(
    # Setting fallback_format to json means that instead of considering
    # the next rule in case of a priority mismatch, json will be used.
    Rule.new(priorities: ["json", "xml"], host: "api.example.com", fallback_format: "json"),
    # Setting fallback_format to false means that instead of considering
    # the next rule in case of a priority mismatch, a 406 will be returned.
    Rule.new(path: /^\/image/, priorities: ["jpeg", "gif"], fallback_format: false),
    # Setting fallback_format to nil (or not including it) means that
    # in case of a priority mismatch the next rule will be considered.
    Rule.new(path: /^\/admin/, priorities: ["xml", "html"]),
    # Setting a priority to */* basically means any format will be matched.
    Rule.new(priorities: ["text/html", "*/*"], fallback_format: "html"),
  )
end

Assuming an accept header with the value text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,application/json: a request made to /foo from the api.example.com hostname; the request format would be json. If the request was not made from that hostname; the request format would be html. The rules can be as complex or as simple as needed depending on the use case of your application.

View Handler#

The ART::View::ViewHandler is responsible for generating an ART::Response in the format determined by the ART::Listeners::Format, otherwise falling back on the request's format, defaulting to json. The view handler has a few configurable options that can be customized if so desired. This can be achieved via redefining Athena::Routing::Config::ViewHandler.configure.

def ART::Config::ViewHandler.configure : ART::Config::ViewHandler
  new(
    # The HTTP::Status to use if there is no response body, defaults to 204.
    empty_content_status: :im_a_teapot,
    # If `nil` values should be serialized, defaults to false.
    emit_nil: true    
  )
end

Usage#

Views#

An ART::View is intended to act as an in between returning raw data and an ART::Response. In other words, it still invokes the view event, but allows customizing the response's status and headers. Convenience methods are defined in the base controller type to make creating views easier. E.g. ART::Controller#view.

View Format Handlers#

By default Athena uses json as the default response format. However it is possible to extend the ART::View::ViewHandler to support additional, and even custom, formats. This is achieved by creating an ART::View::FormatHandlerInterface instance that defines the logic needed to turn an ART::View into an ART::Response.

The implementation can be as simple/complex as needed for the given format. Official handlers could be provided in the future for common formats such as html, probably via an integration with some form of tempting engine utilizing custom annotations to specify the format.

Adding/Customizing Formats#

ART::Request::FORMATS represents the formats supported by default. However this list is not exhaustive and may need altered application to application; such as registering new formats.

Example#

The following is a demonstration of how the various negotiation features can be used in conjunction. The example includes:

  1. Defining a custom ART::View::ViewHandler for the csv format.
  2. Enabling content negotiation, supporting json and csv formats, falling back to json.
  3. An endpoint returning an ART::View that sets a custom HTTP status.
require "athena"
require "csv"

# An interface to denote a type can provide its data in CSV format.
#
# An easier/more robust implementation can probably be thought of,
# however this is mainly for demonstration purposes.
module CSVRenderable
  abstract def to_csv(builder : CSV::Builder) : Nil
end

# Define an example entity type.
record User, id : Int64, name : String, email : String do
  include CSVRenderable
  include JSON::Serializable

  # Define the headers this type has.
  def self.headers : Enumerable(String)
    {
      "id",
      "name",
      "email",
    }
  end

  def to_csv(builder : CSV::Builder) : Nil
    # Add the related values based on `self.`
    builder.row @id, @name, @email
  end
end

# Register our handler as a service.
@[ADI::Register]
class CSVFormatHandler
  # Implement the interface.
  include Athena::Routing::View::FormatHandlerInterface

  # :inherit:
  def call(view_handler : ART::View::ViewHandlerInterface, view : ART::ViewBase, request : ART::Request, format : String) : ART::Response
    view_data = view.data

    headers = if view_data.is_a? Enumerable
                typeof(view_data.first).headers
              else
                view_data.class.headers
              end

    data = if view_data.is_a? Enumerable
             view_data
           else
             {view_data}
           end

    # Assume each item has the same headers.
    content = CSV.build do |csv|
      csv.row headers

      data.each do |r|
        r.to_csv csv
      end
    end

    # Return an ART::Response with the rendered CSV content.
    # Athena handles setting the proper content-type header based on the format.
    # But could be overridden here if so desired.
    ART::Response.new content
  end

  # :inherit:
  def format : String
    "csv"
  end
end

# Configure the format listener.
def ART::Config::ContentNegotiation.configure
  new(
    # Allow json and csv formats, falling back on json if an unsupported format is requested.
    Rule.new(priorities: ["json", "csv"], fallback_format: "json"),
  )
end

class ExampleController < ART::Controller
  @[ARTA::Get("/users")]
  def get_users : ART::View(Array(User))
    self.view([
      User.new(1, "Jim", "jim@example.com"),
      User.new(2, "Bob", "bob@example.com"),
      User.new(3, "Sally", "sally@example.com"),
    ], status: :im_a_teapot)
  end
end

ART.run