Jifty::Plugin::REST::Dispatcher - Dispatcher for REST plugin


Jifty documentation Contained in the Jifty distribution.

Index


Code Index:

NAME

Top

Jifty::Plugin::REST::Dispatcher - Dispatcher for REST plugin

show_help

Shows basic help about resources and formats available via this RESTful interface.

show_help_specific

Displays a help page about a specific topic. Will look for a method named show_help_specific_$1.

Explains /=/search/ a bit more in-depth.

show_version

Displays versions of the various bits of your application.

list PREFIX items

Takes a URL prefix and a set of items to render. passes them on.

output_format [prefix]

Returns the user's desired output format. Returns a hashref of:

    format: JSON, JS, YAML, XML, Perl, or HTML
    extension: json, js, yml, xml, pl, or html
    content_type: text/x-yaml; charset=UTF-8, etc.
    freezer: \&Jifty::YAML::Dump, etc.




outs PREFIX DATASTRUCTURE

Takes a url path prefix and a data structure. Depending on what content types the other side of the HTTP connection can accept, renders the content as YAML, JSON, JavaScript, Perl, XML or HTML.

render_as_xml DATASTRUCTURE

Attempts to render DATASTRUCTURE as simple, tag-based XML.

render_as_html PREFIX URL DATASTRUCTURE

Attempts to render DATASTRUCTURE as simple semantic HTML suitable for humans to look at.

html_dump DATASTRUCTURE

Recursively render DATASTRUCTURE as some simple HTML dls and ols.

html_dump_record Jifty::Record

Returns a nice simple HTML definition list of the keys and values of a Jifty::Record object.

action ACTION

Canonicalizes ACTION into the class-name form preferred by Jifty by cleaning up casing, delimiters, etc. Throws an appropriate HTTP error code if the action is unavailable.

model MODEL

Canonicalizes MODEL into the class-name form preferred by Jifty by cleaning up casing, delimiters, etc. Throws an appropriate HTTP error code if the model is unavailable.

list_models

Sends the user a list of models in this application, with the names transformed from Perlish::Syntax to Everything.Else.Syntax

valid_column

Returns true if the column is a valid column to observe on the model

list_model_columns

Sends the user a nice list of all columns in a given model class. Exactly which model is shoved into $1 by the dispatcher. This should probably be improved.

list_model_items MODELCLASS COLUMNNAME

Returns a list of items in MODELCLASS sorted by COLUMNNAME, with only COLUMNNAME displayed. (This should have some limiting thrown in)

show_item_field $model, $column, $key, $field

Loads up a model of type $model which has a column $column with a value $key. Returns the value of $field for that object. Returns 404 if it doesn't exist.

show_item $model, $column, $key

Loads up a model of type $model which has a column $column with a value $key. Returns all columns for the object

Returns 404 if it doesn't exist.

search_items $model, [c1, v1, c2, v2, ...] [, $field]

Loads up all models of type $model that match the given columns and values. If the column and value list has an odd count, then the last item is taken to be the output column. Otherwise, all items will be returned.

Will throw a 404 if there were no matches, or $field was invalid.

Pseudo-columns:

__per_page => N

Return the collection as N records per page.

__page => N

Return page N of the collection

__order_by => column

Order by the given column, ascending.

__order_by_desc => column

Order by the given column, descending.

create_item

Implemented by redispatching to a CreateModel action.

replace_item

Implemented by redispatching to a CreateModel or UpdateModel action.

delete_item

Implemented by redispatching to a DeleteModel action.

list_actions

Returns a list of all actions visible to the current user. (Canonicalizes Perl::Style to Everything.Else.Style).

list_action_params

Takes a single parameter, $action, supplied by the dispatcher.

Shows the user all possible parameters to the action.

show_action_form $ACTION_CLASS

Takes a single parameter, the class of an action.

Shows the user an HTML form of the action's parameters to run that action.

run_action

Expects $1 to be the name of an action we want to run.

Runs the action, with the HTTP arguments as its arguments. That is, it's not looking for Jifty-encoded (J:F) arguments. If you have an action called "MyApp::Action::Ping" that takes a parameter, ip, this action will look for an HTTP argument called ip, (not J:F-myaction-ip).

Returns the action's result.

TODO, doc the format of the result.

On an invalid action name, throws a 404. On a disallowed action name, throws a 403. On an internal error, throws a 500.


Jifty documentation Contained in the Jifty distribution.
use warnings;
use strict;

package Jifty::Plugin::REST::Dispatcher;




use CGI qw( start_html end_html ol ul li a dl dt dd );
use Carp;
use Jifty::Dispatcher -base;
use Jifty::YAML ();
use Jifty::JSON ();
use Data::Dumper ();
use XML::Simple;

before qr{^ (/=/ .*) \. (js|json|yml|yaml|perl|pl|xml|html) $}x => run {
    Jifty->web->request->env->{HTTP_ACCEPT} = $2;
    dispatch $1;
};

before POST qr{^ (/=/ .*) ! (DELETE|PUT|GET|POST|OPTIONS|HEAD|TRACE|CONNECT) $}x => run {
    Jifty->web->request->method($2);
    Jifty->web->request->env->{REST_REWROTE_METHOD} = 1;
    dispatch $1;
};

on GET    '/=/model/*/*/*/*'    => \&show_item_field;
on GET    '/=/model/*/*/*'      => \&show_item;
on GET    '/=/model/*/*'        => \&list_model_items;
on GET    '/=/model/*'          => \&list_model_columns;
on GET    '/=/model'            => \&list_models;

on POST   '/=/model/*'          => \&create_item;
on PUT    '/=/model/*/*/*'      => \&replace_item;
on DELETE '/=/model/*/*/*'      => \&delete_item;

on GET    '/=/search/*/**'      => \&search_items;

on GET    '/=/action/*'         => \&list_action_params;
on GET    '/=/action'           => \&list_actions;
on POST   '/=/action/*'         => \&run_action;

on GET    '/='                  => \&show_help;
on GET    '/=/help'             => \&show_help;
on GET    '/=/help/*'           => \&show_help_specific;

on GET    '/=/version'          => \&show_version;

sub show_help {
    Jifty->web->response->content_type('text/plain; charset=utf-8');

    Jifty->web->response->body(qq{
Accessing resources:

on GET    /=/model                                   list models
on GET    /=/model/<model>                           list model columns
on GET    /=/model/<model>/<column>                  list model items
on GET    /=/model/<model>/<column>/<key>            show item
on GET    /=/model/<model>/<column>/<key>/<field>    show item field

on POST   /=/model/<model>                           create item
on PUT    /=/model/<model>/<column>/<key>            update item
on DELETE /=/model/<model>/<column>/<key>            delete item

on GET    /=/search/<model>/<c1>/<v1>/<c2>/<v2>/...  search items
on GET    /=/search/<model>/<c1>/<v1>/.../<field>    show matching items' field

on GET    /=/action                                  list actions
on GET    /=/action/<action>                         list action params
on POST   /=/action/<action>                         run action

on GET    /=/help                                    this help page
on GET    /=/help/search                             help for /=/search

on GET    /=/version                                 version information

Resources are available in a variety of formats:

        JSON, JS, YAML, XML, Perl, and HTML

and may be requested in such formats by sending an appropriate HTTP Accept: header
or appending one of the extensions to any resource:

        .json, .js, .yaml, .xml, .pl

HTML is output only if the Accept: header or an extension does not request a
specific format.
});
    last_rule;
}

sub show_help_specific {
    my $topic = $1;
    my $method = "show_help_specific_$topic";
    __PACKAGE__->can($method) or abort(404);

    Jifty->web->response->content_type('text/plain; charset=utf-8');
    Jifty->web->response->body(__PACKAGE__->$method);
    last_rule;
}

sub show_help_specific_search {
    return << 'SEARCH';
This interface supports searching arbitrary columns and values. For example, if
you're looking at a Task with due date 1999-12-25 and complete, you can use:

    /=/search/Task/due/1999-12-25/complete/1

If you're looking for just the summaries of these tasks, you can use:

    /=/search/Task/due/1999-12-25/complete/1/summary

Any column in the model is eligible for searching. If you specify multiple
values for the same column, they'll be ORed together. For example, if you're
looking for Tasks with due dates 1999-12-25 OR 2000-12-25, you can use:

    /=/search/Task/due/1999-12-25/due/2000-12-25/


There are also some pseudo-columns. They are prefixed by __ to avoid collisions
with actual column names.

Not:

    .../__not/<column>/<value>

This lets you search for records whose value for the column is NOT equal
to the specified value.

Ordering:

    .../__order_by/<column>
    .../__order_by_asc/<column>
    .../__order_by_desc/<column>

These let you change the output order of the results. Multiple '__order_by's
will be respected.

Pagination:

    .../__page/<number>
    .../__per_page/<number>

These let you control how many results you'll get.
SEARCH
}

sub show_version {
    outs(['version'], {
        Jifty => $Jifty::VERSION,
        REST  => $Jifty::Plugin::REST::VERSION,
    });
}

sub list {
    my $prefix = shift;
    outs($prefix, \@_)
}

sub output_format {
    my $prefix = shift;
    my $accept = (Jifty->web->request->env->{HTTP_ACCEPT} || '');

    my (@prefix, $url);
    if ($prefix) {
        @prefix = map {s/::/./g; $_} @$prefix;
        $url    = Jifty->web->url(path => join '/', '=',@prefix);
    }

    if ($accept =~ /ya?ml/i) {
        return {
            format       => 'YAML',
            extension    => 'yml',
            content_type => 'text/x-yaml; charset=UTF-8',
            freezer      => \&Jifty::YAML::Dump,
        };
    }
    elsif ($accept =~ /json/i) {
        return {
            format       => 'JSON',
            extension    => 'json',
            content_type => 'application/json; charset=UTF-8',
            freezer      => \&Jifty::JSON::encode_json,
        };
    }
    elsif ($accept =~ /j(?:ava)?s|ecmascript/i) {
        return {
            format       => 'JS',
            extension    => 'js',
            content_type => 'application/javascript; charset=UTF-8',
            freezer      => sub { 'var $_ = ' . Jifty::JSON::encode_json( @_ ) },
        };
    }
    elsif ($accept =~ qr{^(?:application/x-)?(?:perl|pl)$}i) {
        return {
            format       => 'Perl',
            extension    => 'pl',
            content_type => 'application/x-perl; charset=UTF-8',
            freezer      => \&Data::Dumper::Dumper,
        };
    }
    elsif ($accept =~  qr|^(text/)?xml$|i) {
        return {
            format       => 'XML',
            extension    => 'xml',
            content_type => 'text/xml; charset=UTF-8',
            freezer      => \&render_as_xml,
        };
    }
    # if we ever have a non-html fallback case, we should be checking for an
    # $accept of HTML here
    else {
        my $freezer;

        # Special case showing particular actions to show an HTML form
        if (    defined $prefix
            and $prefix->[0] eq 'action'
            and scalar @$prefix == 2 )
        {
            $freezer = sub { show_action_form($prefix->[1]) };
        }
        else {
            $freezer = sub { render_as_html($prefix, $url, @_) };
        }

        return {
            format       => 'HTML',
            extension    => 'html',
            content_type => 'text/html; charset=UTF-8',
            freezer      => $freezer,
        };
    }
}

sub outs {
    my $prefix = shift;
    my $format = output_format($prefix);
    warn "==> using $format->{format}" if $main::DEBUG;

    Jifty->web->response->content_type($format->{content_type});
    Jifty->handler->buffer->out_method->($format->{freezer}->(@_));
    last_rule;
}

our $xml_config = { SuppressEmpty   => undef,
                    NoAttr          => 1,
                    RootName        => 'data' };

sub render_as_xml {
    my $content = shift;

    if (ref($content) eq 'ARRAY') {
        return XMLout({value => $content}, %$xml_config);
    }
    elsif (ref($content) eq 'HASH') {
        return XMLout($content, %$xml_config);
    } else {
        return XMLout({value => $content}, %$xml_config)
    }
}


sub render_as_html {
    my $prefix = shift;
    my $url = shift;
    my $content = shift;

    my $title = _("%1 - REST API", Jifty->config->framework('ApplicationName'));

    if (ref($content) eq 'ARRAY') {
        return start_html(-encoding => 'UTF-8', -declare_xml => 1, -title => $title),
              ul(map {
                ref($_) eq 'HASH' ? render_as_html($url, $prefix,$_) :
                    li(
                    ref($_) eq 'ARRAY' ? render_as_html($url, $prefix,$_) :
                      
                      ($prefix ?
                     a({-href => "$url/".Jifty::Web->escape_uri($_)}, Jifty::Web->escape($_))
                     : Jifty::Web->escape($_) ))
              } @{$content}),
              end_html();
    }
    elsif (ref($content) eq 'HASH') {
        return start_html(-encoding => 'UTF-8', -declare_xml => 1, -title => $title),
              dl(map {
                  dt($prefix ?
                     a({-href => "$url/".Jifty::Web->escape_uri($_)}, Jifty::Web->escape($_))
                     : Jifty::Web->escape($_)),
                  dd(html_dump($content->{$_})),
              } sort keys %{$content}),
              end_html();
    }
    else {
        return start_html(-encoding => 'UTF-8', -declare_xml => 1, -title => $title),
              Jifty::Web->escape($content),
              end_html();
    }
}



sub html_dump {
    my $content = shift;
    if (ref($content) eq 'ARRAY') {
        if (@$content) {
            return ul(map {
                li(html_dump($_))
            } @{$content});
        }
        else {
            return;
        }
    }
    elsif (ref($content) eq 'HASH') {
        if (keys %$content) {
            return dl(map {
                dt(Jifty::Web->escape($_)),
                dd(html_dump($content->{$_})),
            } sort keys %{$content});
        }
        else {
            return;
        }
    
    } elsif (ref($content) && $content->isa('Jifty::Collection')) {
        if ($content->count) {
            return  ol( map { li( html_dump_record($_))  } @{$content->items_array_ref});
        }
        else {
            return;
        }
        
    } elsif (ref($content) && $content->isa('Jifty::Record')) {
          return   html_dump_record($content);
    }
    else {
        Jifty::Web->escape($content);
    }
}


sub html_dump_record {
    my $item = shift;
     my %hash = $item->as_hash;

     return  dl( map {dt($_), dd($hash{$_}) } keys %hash )
}


sub action {
    _resolve(
        name          => $_[0],
        base          => 'Jifty::Action',
        possibilities => [Jifty->api->visible_actions],
# We do not do this check because we want users to see actions on GET requests,
# like when they're exploring the REST API in their browser.
#        is_allowed    => sub { Jifty->api->is_allowed(shift) },
    );
}

sub model {
    _resolve(
        name          => $_[0],
        base          => 'Jifty::Record',
        possibilities => [Jifty->class_loader->models],
        is_allowed    => sub { not shift->is_private },
    );
}

sub _resolve {
    my %args = @_;

    # we display actions as "AppName.Action.Foo", so we want to convert those
    # heathen names to be Perl-style
    $args{name} =~ s/\./::/g;

    my $re = qr/(?:^|::)\Q$args{name}\E$/i;

    my $hit;
    foreach my $class (@{ $args{possibilities} }) {
        if ($class =~ $re && $class->isa($args{base})) {
            $hit = $class;
            last;
        }
    }

    abort(404) if !defined($hit);

    abort(403) if $args{is_allowed} && !$args{is_allowed}->($hit);

    return $hit;
}


sub list_models {
    list(['model'], map { s/::/./g; $_ } grep {not $_->is_private} Jifty->class_loader->models);
}

our @column_attrs = 
qw( name
    documentation
    type
    default
    readable writable
    display_length
    max_length
    mandatory
    distinct
    sort_order
    refers_to
    by
    alias_for_column
    aliased_as
    label hints
    valid_values
);

sub valid_column {
    my ( $model, $column ) = @_;
    return scalar grep { $_->name eq $column and not $_->virtual and not $_->private } $model->new->columns;
}

sub list_model_columns {
    my ($model) = model($1);

    my %cols;
    for my $col ( $model->new->columns ) {
        next if $col->private or $col->virtual;
        $cols{ $col->name } = { };
        for ( @column_attrs ) {
            my $val = $col->$_();
            $cols{ $col->name }->{ $_ } = Scalar::Defer::force($val)
                if defined $val and length $val;
        }
        if (my $serialized = $model->column_serialized_as($col)) {
            $cols{ $col->name }->{serialized_as} = $serialized;
        }
        $cols{ $col->name }{writable} = 0 if exists $cols{$col->name}{writable} and $col->protected;
    }

    outs( [ 'model', $model ], \%cols );
}


sub list_model_items {
    # Normalize model name - fun!
    my ( $model, $column ) = ( model($1), $2 );
    my $col = $model->new->collection_class->new;
    $col->unlimit;

    # Check that the field is actually a column
    abort(404) unless valid_column($model, $column);

    # If we don't load the PK, we won't get data
    $col->columns("id", $column);
    $col->order_by( column => $column );

    list( [ 'model', $model, $column ],
        map { Jifty::Util->stringify($_->$column()) }
            @{ $col->items_array_ref || [] } );
}


sub show_item_field {
    my ( $model, $column, $key, $field ) = ( model($1), $2, $3, $4 );
    my $rec = $model->new;
    $rec->load_by_cols( $column => $key );
    $rec->id          or abort(404);
    $rec->can($field) or abort(404);

    # Check that the field is actually a column (and not some other method)
    abort(404) unless valid_column($model, $field);

    outs( [ 'model', $model, $column, $key, $field ],
          Jifty::Util->stringify($rec->$field()) );
}

sub show_item {
    my ($model, $column, $key) = (model($1), $2, $3);
    my $rec = $model->new;

    # Check that the field is actually a column
    abort(404) unless valid_column($model, $column);

    $rec->load_by_cols( $column => $key );
    $rec->id or abort(404);
    $rec->current_user_can('read') or abort(403);

    outs( ['model', $model, $column, $key], $rec->jifty_serialize_format );
}

sub search_items {
    my ($model, $fragment) = (model($1), $2);
    my @pieces = grep {length} split '/', $fragment;
    my $ret = ['search', $model, @pieces];

    # limit to the key => value pairs they gave us
    my $collection = eval { $model->collection_class->new }
        or abort(404);
    $collection->unlimit;

    my $record = $model->new
        or abort(404);

    my $added_order = 0;
    my $per_page;
    my $current_page = 1;

    my %special = (
        __per_page => sub {
            my $N = shift;

            # must be a number
            $N =~ /^\d+$/
                or abort(404);

            $per_page = $N;
        },
        __page => sub {
            my $N = shift;

            # must be a number
            $N =~ /^\d+$/
                or abort(404);

            $current_page = $N;
        },
        __order_by => sub {
            my $col = shift;
            my $order = shift || 'ASC';

            # this will wipe out the default ordering on your model the first
            # time around
            if ($added_order) {
                $collection->add_order_by(
                    column => $col,
                    order  => $order,
                );
            }
            else {
                $added_order = 1;
                $collection->order_by(
                    column => $col,
                    order  => $order,
                );
            }
        },
        __not => sub {
            my $column = shift;
            my $value  = shift @pieces;

            my $canonicalizer = "canonicalize_$column";
            $value = $record->$canonicalizer($value)
                if $record->can($canonicalizer);

            $collection->limit(
                column   => $column,
                value    => $value,
                operator => '!=',
            );
        },
    );

    # this was called __limit before it was generalized
    $special{__limit} = $special{__per_page};

    # /__order_by/name/desc is impossible to distinguish between ordering by
    # 'name', descending, and ordering by 'name', with output column 'desc'.
    # so we use __order_by_desc instead (and __order_by_asc is provided for
    # consistency)
    $special{__order_by_asc}  = $special{__order_by};
    $special{__order_by_desc} = sub { $special{__order_by}->($_[0], 'DESC') };

    while (@pieces > 1) {
        my $column = shift @pieces;
        my $value  = shift @pieces;

        if (exists $special{$column}) {
            $special{$column}->($value);
        }
        else {
            my $canonicalizer = "canonicalize_$column";
            $value = $record->$canonicalizer($value)
                if $record->can($canonicalizer);

            $collection->limit(column => $column, value => $value);
        }
    }

    # if they provided an odd number of pieces, the last is the output column
    my $field;
    if (@pieces) {
        $field = shift @pieces;
    }

    if (defined($per_page) || defined($current_page)) {
        $per_page = 15 unless defined $per_page;
        $current_page = 1 unless defined $current_page;
        $collection->set_page_info(
            current_page => $current_page,
            per_page     => $per_page,
        );
    }

    $collection->count                       or return outs($ret, []);
    $collection->pager->entries_on_this_page or return outs($ret, []);

    # output
    if (defined $field) {
        my $item = $collection->first
            or return outs($ret, []);

        # Check that the field is actually a column
        abort(404) unless valid_column($model, $field);

        my @values;

        # collect the values for $field
        do {
            push @values, $item->$field;
        } while $item = $collection->next;

        outs($ret, \@values);
    }
    else {
        outs($ret, $collection->jifty_serialize_format);
    }
}

sub create_item { _dispatch_to_action('Create') }

sub replace_item { _dispatch_to_action('Update') }

sub delete_item { _dispatch_to_action('Delete') }

sub _dispatch_to_action {
    my $prefix = shift;
    my ($model, $class, $column, $key) = (model($1), $1, $2, $3);
    my $rec = $model->new;
    $rec->load_by_cols( $column => $key )
        if defined $column and defined $key;

    if ( not $rec->id ) {
        abort(404) if $prefix eq 'Delete' || $prefix eq 'Update';
    }

    $class =~ s/^[\w\.]+\.//;

    # 403 unless the action exists
    my $action = action($prefix . $class);

    if ( defined $column and defined $key ) {
        Jifty->web->request->argument( $column => $key );
        Jifty->web->request->argument( 'id' => $rec->id )
            if defined $rec->id;
    }
    
    Jifty->web->request->method('POST');
    dispatch "/=/action/$action";
}

sub list_actions {
    list(['action'], map {s/::/./g; $_} Jifty->api->visible_actions);
}

our @param_attrs = qw(
    name
    documentation
    type
    default_value
    label
    hints
    mandatory
    ajax_validates
    length
    valid_values
);

sub list_action_params {
    my ($class) = action($1);
    my $action = $class->new or abort(404);

    my $arguments = $action->arguments;
    my %args;
    for my $arg ( keys %$arguments ) {
        $args{ $arg } = { };
        for ( @param_attrs ) {
            # Valid values is special because sometimes it has a collection
            # object that needs to be abstracted away
            my $val = $_ eq 'valid_values'
                        ? $action->valid_values($arg)
                        : $arguments->{ $arg }{ $_ };
            $args{ $arg }->{ $_ } = Scalar::Defer::force($val)
                if defined $val and length $val;
        }
    }

    outs( ['action', $class], \%args );
}

sub show_action_form {
    my ($action) = action(shift);
    Jifty::Util->require($action) or abort(404);
    $action = $action->new or abort(404);
    # XXX - Encapsulation?  Someone please think of the encapsulation!
    no warnings 'redefine';
    local *Jifty::Action::form_field_name = sub { shift; $_[0] };
    local *Jifty::Action::register = sub { 1 };
    local *Jifty::Web::Form::Field::Unrendered::render = \&Jifty::Web::Form::Field::render;

    Jifty->web->response->{body} .= start_html(-encoding => 'UTF-8', -declare_xml => 1, -title => ref($action));
    Jifty->web->form->start;
    for my $name ($action->argument_names) {
        Jifty->web->response->{body} .= $action->form_field($name);
    }
    Jifty->web->form->submit( label => 'POST' );
    Jifty->web->form->end;
    Jifty->web->response->{body} .= end_html;
    last_rule;
}

sub run_action {
    my ($action_name) = action($1);
    Jifty::Util->require($action_name) or abort(404);
    
    my $args = Jifty->web->request->arguments;
    delete $args->{''};

    my $action = $action_name->new( arguments => $args ) or abort(404);

    Jifty->api->is_allowed( $action_name ) or abort(403);

    $action->validate;

    local $@;
    eval { $action->run };

    if ($@) {
        warn $@;
        abort(500);
    }

    my $rec = $action->{record};
    if ($action->result->success && $rec and $rec->isa('Jifty::Record') and $rec->id and ($rec->load($rec->id))[0]) {
        my @fragments = ('model', ref($rec), 'id', $rec->id);

        my $path = join '/', '=', map { Jifty::Web->escape_uri($_) } @fragments;

        my $extension = output_format(\@fragments)->{extension};
        $path .= '.' . $extension;

        my $url = Jifty->web->url(path => $path);

        Jifty->web->response->status( 302 );
        Jifty->web->response->header('Location' => $url);
    }

    outs(undef, $action->result->as_hash);

    last_rule;
}

1;