package Jifty::Plugin::REST::Dispatcher; use warnings; 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) $}x => run { $ENV{HTTP_ACCEPT} = $2; dispatch $1; }; before POST qr{^ (/=/ .*) ! (DELETE|PUT|GET|POST|OPTIONS|HEAD|TRACE|CONNECT) $}x => run { $ENV{REQUEST_METHOD} = $2; 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 '/=/action/*' => \&list_action_params; on GET '/=/action' => \&list_actions; on POST '/=/action/*' => \&run_action; on GET '/=/help' => \&show_help; =head2 show_help Shows basic help about resources and formats available via this RESTian interface. =cut sub show_help { my $apache = Jifty->handler->apache; $apache->header_out('Content-Type' => 'text/plain; charset=UTF-8'); $apache->send_http_header; print qq{ Accessing resources: on GET /=/model list models on GET /=/model/ list model columns on GET /=/model// list model items on GET /=/model/// show item on GET /=/model//// show item field on POST /=/model/ create item on PUT /=/model/// update item on DELETE /=/model/// delete item on GET /=/action list actions on GET /=/action/ list action params on POST /=/action/ run action 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; } =head2 stringify LIST Takes a list of values and forces them into strings. Right now all it does is concatenate them to an empty string, but future versions might be more magical. =cut sub stringify { no warnings 'uninitialized'; my @r = map { defined $_ ? '' . $_ : undef } @_; return wantarray ? @r : pop @r; } =head2 object_to_data OBJ Takes an object and converts the known types into simple data structures. Current known types: Jifty::DBI::Collection Jifty::DBI::Record =cut sub object_to_data { my $obj = shift; my %types = ( 'Jifty::DBI::Collection' => \&_collection_to_data, 'Jifty::DBI::Record' => \&_record_to_data, ); for my $type ( keys %types ) { if ( UNIVERSAL::isa( $obj, $type ) ) { return $types{$type}->( $obj ); } } # As the last resort, return the object itself and expect the $accept-specific # renderer to format the object as e.g. YAML or JSON data. return $obj; } sub _collection_to_data { my $records = shift->items_array_ref; return [ map { _record_to_data( $_ ) } @$records ]; } sub _record_to_data { my $record = shift; # We could use ->as_hash but this method avoids transforming refers_to # columns into JDBI objects my %data = map { $_ => (UNIVERSAL::isa( $record->column( $_ )->refers_to, 'Jifty::DBI::Collection' ) ? undef : stringify( $record->_value( $_ ) ) ) } $record->readable_attributes; return \%data; } =head2 list PREFIX items Takes a URL prefix and a set of items to render. passes them on. =cut sub list { my $prefix = shift; outs($prefix, \@_) } =head2 outs PREFIX DATASTRUCTURE TAkes a url path prefix and a datastructure. 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. =cut sub outs { my $prefix = shift; my $accept = ($ENV{HTTP_ACCEPT} || ''); my $apache = Jifty->handler->apache; my @prefix; my $url; if($prefix) { @prefix = map {s/::/./g; $_} @$prefix; $url = Jifty->web->url(path => join '/', '=',@prefix); } if ($accept =~ /ya?ml/i) { $apache->header_out('Content-Type' => 'text/x-yaml; charset=UTF-8'); $apache->send_http_header; print Jifty::YAML::Dump(@_); } elsif ($accept =~ /json/i) { $apache->header_out('Content-Type' => 'application/json; charset=UTF-8'); $apache->send_http_header; print Jifty::JSON::objToJson( @_, { singlequote => 1 } ); } elsif ($accept =~ /j(?:ava)?s|ecmascript/i) { $apache->header_out('Content-Type' => 'application/javascript; charset=UTF-8'); $apache->send_http_header; print 'var $_ = ', Jifty::JSON::objToJson( @_, { singlequote => 1 } ); } elsif ($accept =~ qr{^(?:application/x-)?(?:perl|pl)$}i) { $apache->header_out('Content-Type' => 'application/x-perl; charset=UTF-8'); $apache->send_http_header; print Data::Dumper::Dumper(@_); } elsif ($accept =~ qr|^(text/)?xml$|i) { $apache->header_out('Content-Type' => 'text/xml; charset=UTF-8'); $apache->send_http_header; print render_as_xml(@_); } else { $apache->header_out('Content-Type' => 'text/html; charset=UTF-8'); $apache->send_http_header; # Special case showing particular actions to show an HTML form if ( defined $prefix and $prefix->[0] eq 'action' and scalar @$prefix == 2 ) { show_action_form($1); } else { print render_as_html($prefix, $url, @_); } } last_rule; } our $xml_config = { SuppressEmpty => '', NoAttr => 1, RootName => 'data' }; =head2 render_as_xml DATASTRUCTURE Attempts to render DATASTRUCTURE as simple, tag-based XML. =cut 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) } } =head2 render_as_html PREFIX URL DATASTRUCTURE Attempts to render DATASTRUCTURE as simple semantic HTML suitable for humans to look at. =cut sub render_as_html { my $prefix = shift; my $url = shift; my $content = shift; if (ref($content) eq 'ARRAY') { return start_html(-encoding => 'UTF-8', -declare_xml => 1, -title => 'models'), ul(map { li($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 => 'models'), 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 => 'models'), Jifty::Web->escape($content), end_html(); } } =head2 html_dump DATASTRUCTURE Recursively render DATASTRUCTURE as some simple html dls and ols. =cut sub html_dump { my $content = shift; if (ref($content) eq 'ARRAY') { ul(map { li(html_dump($_)) } @{$content}); } elsif (ref($content) eq 'HASH') { dl(map { dt(Jifty::Web->escape($_)), dd(html_dump($content->{$_})), } sort keys %{$content}), } elsif (ref($content) && $content->isa('Jifty::Collection')) { return ol( map { li( html_dump_record($_)) } @{$content->items_array_ref}); } elsif (ref($content) && $content->isa('Jifty::Record')) { return html_dump_record($content); } else { Jifty::Web->escape($content); } } =head2 html_dump_record Jifty::Record Returns a nice simple HTML definition list of the keys and values of a Jifty::Record object. =cut sub html_dump_record { my $item = shift; my %hash = $item->as_hash; return dl( map {dt($_), dd($hash{$_}) } keys %hash ) } =head2 action ACTION Canonicalizes ACTION into the form preferred by the code. (Cleans up casing, canonicalizing, etc. Returns 404 if it can't work its magic =cut sub action { _resolve($_[0], 'Jifty::Action', Jifty->api->actions) } =head2 model MODEL Canonicalizes MODEL into the form preferred by the code. (Cleans up casing, canonicalizing, etc. Returns 404 if it can't work its magic =cut sub model { _resolve($_[0], 'Jifty::Record', Jifty->class_loader->models) } sub _resolve { my $name = shift; my $base = shift; return $name if $name->isa($base); $name =~ s/\W+/\\W+/g; foreach my $cls (@_) { return $cls if $cls =~ /$name$/i; } abort(404); } =head2 list_models Sends the user a list of models in this application, with the names transformed from Perlish::Syntax to Everything.Else.Syntax =cut sub list_models { list(['model'], map { s/::/./g; $_ } Jifty->class_loader->models); } our @column_attrs = qw( name type default readable writable max_length mandatory distinct sort_order refers_to alias_for_column aliased_as label hints valid_values ); =head2 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. =cut sub list_model_columns { my ($model) = model($1); my %cols; for my $col ( $model->new->columns ) { $cols{ $col->name } = { }; for ( @column_attrs ) { my $val = $col->$_(); $cols{ $col->name }->{ $_ } = $val if defined $val and length $val; } } outs( [ 'model', $model ], \%cols ); } =head2 list_model_items MODELCLASS COLUMNNAME Returns a list of items in MODELCLASS sorted by COLUMNNAME, with only COLUMNAME displayed. (This should have some limiting thrown in) =cut sub list_model_items { # Normalize model name - fun! my ( $model, $column ) = ( model($1), $2 ); my $col = $model->new->collection_class->new; $col->unlimit; # 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 { stringify($_->$column()) } @{ $col->items_array_ref || [] } ); } =head2 show_item_field $model, $column, $key, $field Loads up a model of type C<$model> which has a column C<$column> with a value C<$key>. Returns the value of C<$field> for that object. Returns 404 if it doesn't exist. =cut 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) if not scalar grep { $_->name eq $field } $rec->columns; outs( [ 'model', $model, $column, $key, $field ], stringify($rec->$field()) ); } =head2 show_item $model, $column, $key Loads up a model of type C<$model> which has a column C<$column> with a value C<$key>. Returns all columns for the object Returns 404 if it doesn't exist. =cut sub show_item { my ($model, $column, $key) = (model($1), $2, $3); my $rec = $model->new; $rec->load_by_cols( $column => $key ); $rec->id or abort(404); outs( ['model', $model, $column, $key], { map {$_ => stringify($rec->$_())} map {$_->name} $rec->columns}); } =head2 create_item Implemented by redispatching to a CreateModel action. =cut sub create_item { _dispatch_to_action('Create') } =head2 replace_item Implemented by redispatching to a CreateModel or UpdateModel action. =cut sub replace_item { _dispatch_to_action('Update') } =head2 delete_item Implemented by redispatching to a DeleteModel action. =cut 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 = 'Create' if $prefix eq 'Update'; } $class =~ s/^[\w\.]+\.//; if ( defined $column and defined $key ) { Jifty->web->request->argument( $column => $key ); Jifty->web->request->argument( 'id' => $rec->id ) if defined $rec->id; } # CGI.pm doesn't handle form encoded data in PUT requests (in fact, # it doesn't really handle PUT requests properly at all), so we have # to read the request body ourselves and have CGI.pm parse it if ( $ENV{'REQUEST_METHOD'} eq 'PUT' and ( $ENV{'CONTENT_TYPE'} =~ m|^application/x-www-form-urlencoded$| or $ENV{'CONTENT_TYPE'} =~ m|^multipart/form-data$| ) ) { my $cgi = Jifty->handler->cgi; my $length = defined $ENV{'CONTENT_LENGTH'} ? $ENV{'CONTENT_LENGTH'} : 0; my $data; $cgi->read_from_client( \$data, $length, 0 ) if $length > 0; if ( defined $data ) { my @params = $cgi->all_parameters; $cgi->parse_params( $data ); push @params, $cgi->all_parameters; my %seen; my @uniq = map { $seen{$_}++ == 0 ? $_ : () } @params; # Add only the newly parsed arguments to the Jifty::Request Jifty->web->request->argument( $_ => $cgi->param( $_ ) ) for @uniq; } } $ENV{REQUEST_METHOD} = 'POST'; dispatch '/=/action/' . action( $prefix . $class ); } =head2 list_actions Returns a list of all actions allowed to the current user. (Canonicalizes Perl::Style to Everything.Else.Style). =cut sub list_actions { list(['action'], map {s/::/./g; $_} Jifty->api->actions); } =head2 list_action_params Takes a single parameter, $action, supplied by the dispatcher. Shows the user all possible parameters to the action. =cut our @param_attrs = qw( name type default_value label hints mandatory length ); sub list_action_params { my ($class) = action($1) or abort(404); Jifty::Util->require($class) or abort(404); my $action = $class->new or abort(404); my $arguments = $action->arguments; my %args; for my $arg ( keys %$arguments ) { $args{ $arg } = { }; for ( @param_attrs ) { my $val = $arguments->{ $arg }{ $_ }; $args{ $arg }->{ $_ } = $val if defined $val and length $val; } } outs( ['action', $class], \%args ); } =head2 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. =cut sub show_action_form { my ($action) = action(shift) or abort(404); 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::Web::out = sub { shift; print @_ }; 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; print start_html(-encoding => 'UTF-8', -declare_xml => 1, -title => ref($action)); Jifty->web->form->start; for my $name ($action->argument_names) { print $action->form_field($name); } Jifty->web->form->submit( label => 'POST' ); Jifty->web->form->end; print end_html; last_rule; } =head2 run_action Expects $1 to be the name of an action we want to run. Runs the action, I. 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, C, this action will look for an HTTP argument called C, (not J:F-myaction-ip). Returns the action's result. TODO, doc the format of the result. On an invalid action name, throws a C<404>. On a disallowed action mame, throws a C<403>. On an internal error, throws a C<500>. =cut sub run_action { my ($action_name) = action($1) or abort(404); 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); my $params = $action->arguments; for my $key ( keys %$params ) { next if not exists $params->{ $key }{'default_value'}; next if $action->has_argument( $key ); $action->argument_value( $key => $params->{ $key }{'default_value'} ); } $action->validate; local $@; eval { $action->run }; if ($@) { abort(500); } my $rec = $action->{record}; if ($action->result->success && $rec and $rec->isa('Jifty::Record') and $rec->id) { my $url = Jifty->web->url(path => join '/', '=', map { Jifty::Web->escape_uri($_) } 'model', ref($rec), 'id', $rec->id); Jifty->handler->apache->header_out('Location' => $url); } my $result = $action->result; my $out = {}; $out->{success} = $result->success; $out->{message} = $result->message; $out->{error} = $result->error; $out->{field_errors} = {$result->field_errors}; for (keys %{$out->{field_errors}}) { delete $out->{field_errors}->{$_} unless $out->{field_errors}->{$_}; } $out->{field_warnings} = {$result->field_warnings}; for (keys %{$out->{field_warnings}}) { delete $out->{field_warnings}->{$_} unless $out->{field_warnings}->{$_}; } $out->{content} = $result->content; for my $key ( keys %{ $out->{content} } ) { $out->{content}{$key} = object_to_data( $out->{content}{$key} ); } outs(undef, $out); last_rule; } 1;