The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Plack::Middleware::Negotiate;
{
  $Plack::Middleware::Negotiate::VERSION = '0.04';
}
#ABSTRACT: Apply HTTP content negotiation as Plack middleware
use strict;
use warnings;
use v5.10.1;
use parent 'Plack::Middleware';

use Plack::Util::Accessor qw(formats parameter extension);
use Plack::Request;
use HTTP::Negotiate qw(choose);
use Carp qw(croak);

use Log::Contextual::WarnLogger;
use Log::Contextual qw(:log), 
    -default_logger => Log::Contextual::WarnLogger->new({ 
        env_prefix => 'PLACK_MIDDLEWARE_NEGOTIATE' });

sub prepare_app {
    my $self = shift;

    croak __PACKAGE__ . ' requires formats'
        unless $self->{formats} and %{$self->{formats}};

    $self->{formats}->{_} //= { };

    unless ($self->{formats}->{_}->{type}) {
        foreach (grep { $_ ne '_' } keys %{$self->{formats}}) {
            croak __PACKAGE__ . " format requires type: $_"
                unless $self->{formats}->{$_}->{type};
        }
    }

    $self->app( sub {
        [ 406, ['Content-Type'=>'text/plain'], ['Not Acceptable']];
    } ) unless $self->app;
}

sub call {
    my ($self, $env) = @_;

    my $orig_path = $env->{PATH_INFO};

    my $format = $self->negotiate($env);
    $env->{'negotiate.format'} = $format;

    my $app;
    $app = $self->{formats}->{$format}->{app} if $format and $self->{formats}->{$format};
    $app //= $self->app;

    Plack::Util::response_cb( $app->($env), sub {
        my $res = shift;
        $self->add_headers( $res->[1], $env->{'negotiate.format'} );
        $env->{PATH_INFO} = $orig_path;
        $res;
    });
}

sub add_headers {
    my ($self, $headers, $name) = @_;

    my $format = $self->about($name) || return;
    my $fields = { @$headers };

    if (!$fields->{'Content-Type'}) {
        my $type = $format->{type};
        $type .= "; charset=". $format->{charset}
            if $format->{charset};
        push @$headers, 'Content-Type' => $type;
    }

    push @$headers, 'Content-Language' => $format->{language}
        if $format->{language} and !$fields->{'Content-Language'};
}

sub negotiate {
    my ($self, $env) = @_;
    my $req = Plack::Request->new($env);

    if (defined $self->parameter) {
        my $format = $req->param($self->parameter);
        if ( ($format // '_') ne '_' and $self->{formats}->{$format}) {
            log_trace { "format $format chosen based on query parameter" };
            return $format;
        }
    }

    if ($self->extension and $req->path =~ /\.([^.]+)$/ 
            and $self->formats->{$1}) {
        my $format = $1;
        $env->{PATH_INFO} =~ s/\.$format$//
            if $self->extension eq 'strip';
        log_trace { "format $format chosen based on extension" };
        return $format;
    }

    my $format = choose($self->variants, $req->headers);
    log_trace { "format $format chosen based on HTTP content negotiation" };
    return $format;
}

sub about {
    my ($self, $name) = @_;

    return unless defined $name and $name ne '_';

    my $default = $self->{formats}->{_};
    my $format  = $self->{formats}->{$name} || return;

    return {
        quality  => $format->{quality} // $default->{quality} // 1,
        type     => $format->{type} // $default->{type},
        encoding => $format->{encoding} // $default->{encoding},
        charset  => $format->{charset} // $default->{charset},
        language => $format->{language} // $default->{language},
    };
}

sub variants {
    my $self = shift;
    return [ 
        map { 
            my $format = $self->about($_);
            [ 
                $_, 
                $format->{quality},
                $format->{type}, 
                $format->{encoding},
                $format->{charset},
                $format->{language},
                0 
        ] } 
        grep { $_ ne '_' } keys %{$self->{formats}}
    ];
}

1;


__END__
=pod

=head1 NAME

Plack::Middleware::Negotiate - Apply HTTP content negotiation as Plack middleware

=head1 VERSION

version 0.04

=head1 SYNOPSIS

    builder {
        enable 'Negotiate',
            formats => {
                xml  => { 
                    type    => 'application/xml',
                    charset => 'utf-8',
                },
                html => { type => 'text/html', language => 'en' },
                _    => { size => 0 }  # default values for all formats           
            },
            parameter => 'format', # e.g. http://example.org/foo?format=xml
            extension => 'strip';  # e.g. http://example.org/foo.xml
        $app; # neither html nor xml requested
    };

=head1 DESCRIPTION

Plack::Middleware::Negotiate applies HTTP content negotiation to a L<PSGI>
request. The PSGI environment key C<negotiate.format> is set to the chosen
format name. In addition to normal content negotiation one may enable explicit
format selection with a path extension or query parameter. The middleware takes
care for rewriting and restoring PATH_INFO if it is configured to detect and
strip a format extension. The PSGI response is enriched with corresponding HTTP
headers Content-Type and Content-Language unless these headers already exist.

If used as pure application, this middleware returns a HTTP status code 406 if
no format could be negotiated.

=head1 METHODS

=head2 new ( formats => { ... } [ %argument ] )

Creates a new negotiation middleware with a given set of formats. The argument
C<parameter> can be added to support explicit format selection with a query
parameter. The argument C<extension> can be used to support explicit format
selection with a virtual file extension. Use C<< format => 'strip' >> to strip
a known format name from the request path and C<< format => 'keep' >> to keep
it. Each format can be defined with C<type>, C<quality> (defaults to 1),
C<encoding>, C<charset>, and C<language>. The special format name C<_>
(underscore) is reserved to define default values for all formats.

Formats can also be used to directly route the request to a PSGI application:

    my $app = Plack::Middleware::Negotiate->new(
        formats => {
            json => { 
                type => 'application/json',
                app  => $json_app,
            },
            html => {
                type => 'text/html',
                app  => $html_app,
            }
        }
    );

=head2 negotiate ( $env )

Chooses a format based on a PSGI request. The request is first checked for
explicit format selection via C<parameter> and C<extionsion> (if configured)
and then passed to L<HTTP::Negotiate>. Returns the format name. May modify the
PSGI request environment keys PATH_INFO and SCRIPT_NAME if format was selected
by extension set to C<strip>.

=head2 about ( $format )

If the format was specified, this method returns a hash with C<quality>,
C<type>, C<encoding>, C<charset>, and C<language>. Missing values are set to
the default.

=head2 variants ()

Returns a list of content variants to be used in L<HTTP::Negotiate>. The return
value is an array reference of array references, each with seven elements:
format name, source quality, type, encoding, charset, language, and size. The
size is always zero.

=head2 add_headers ( \@headers, $format )

Add apropriate HTTP response headers for a format unless the headers are
already given.

=encoding utf8

=head1 METHODS

=head1 LOGGING AND DEBUGGUNG

Plack::Middleware::Negotiate uses C<Log::Contextual> to emit a logging message
during content negotiation on logging level <trace>. Just set:

    $ENV{PLACK_MIDDLEWARE_NEGOTIATE_TRACE} = 1;

=head1 LIMITATIONS

The Content-Encoding HTTP response header is not automatically set on a
response and content negotiation based on size is not supported. Feel free to
comment on whether and how this middleware should support both.

=head1 SEE ALSO

L<HTTP::Negotiate>, L<HTTP::Headers::ActionPack::ContentNegotiation>

=head1 AUTHOR

Jakob Voß <voss@gbv.de>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2012 by Jakob Voß.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut