#!/usr/bin/perl # # DW::Controller::API::REST # # REST API. # # Authors: # Allen Petersen # # Copyright (c) 2016 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl itself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # package DW::Controller::API::REST; use strict; use v5.10; use Log::Log4perl; my $log = Log::Log4perl->get_logger(__PACKAGE__); use Carp qw/ croak /; use Hash::MultiValue; use JSON; use JSON::Validator 'validate_json'; use YAML::XS qw'LoadFile'; use DW::API::Key; use DW::API::Method; use DW::API::Parameter; use DW::Controller; use DW::Controller::API; use DW::Request; use DW::Routing; our %API_DOCS = (); our %TYPE_REGEX = ( string => '([^/]+)', integer => '(\d+)', boolean => '(true|false)', ); our %METHODS = ( get => 1, post => 1, delete => 1, put => 1 ); our $API_PATH = "$ENV{LJHOME}/api/dist/"; # Usage: path ( yaml_source_path, ver, hash_of_HTTP_handlers ) # Creates a new path object for use in DW::Controller::API::REST #resource definitions from a OpenAPI-compliant YAML file and handler sub references sub path { my ( $class, $source, $ver, $handlers ) = @_; my $config = LoadFile( $API_PATH . $source ); my $route = { ver => $ver }; my $path; for my $key ( keys %{ $config->{paths} } ) { $route->{'path'}{'name'} = $key; $path = $key; } bless $route, $class; if ( exists $config->{paths}->{$path}->{parameters} ) { for my $param ( @{ $config->{paths}->{$path}->{parameters} } ) { my $new_param = DW::API::Parameter->define_parameter($param); $route->{path}{params}{ $param->{name} } = $new_param; } delete $config->{paths}->{$path}->{parameters}; } for my $method ( keys %{ $config->{paths}->{$path} } ) { # make sure that it's a valid HTTP method, and we have a handler for it die "$method isn't a valid HTTP method" unless $METHODS{$method}; die "No handler sub was passed for $method" unless $handlers->{$method}; my $method_config = $config->{paths}->{$path}->{$method}; $route->_add_method( $method, $handlers->{$method}, $method_config ); } register_rest_controller($route); return $route; } sub _add_method { my ( $self, $method, $handler, $config ) = @_; my $new_method = DW::API::Method->define_method( $method, $handler, $config ); # add method params if ( exists $config->{parameters} ) { for my $param ( @{ $config->{parameters} } ) { $new_method->param($param); } } if ( exists $config->{requestBody} ) { $new_method->body( $config->{requestBody} ); } $self->{path}->{methods}->{$method} = $new_method; } # Usage: DW::Controller::API::REST->register_rest_endpoints( $resource , $ver ); # # Validates given API resource object's route path, substitutes parameters with # their regex representation, and then registers that path in the routing table # with the generic handler _dispatcher and the defining resoure object. Adds # the resource object to the %API_DOCS hash for building our API documentation. sub register_rest_controller { my ($info) = shift; my $path = $info->{path}{name}; my $parameters = $info->{path}{params}; my $ver = $info->{ver}; $API_DOCS{$ver}{$path} = $info; # check path parameters to make sure they're defined in the API docs # substitute appropriate regex if they are my @params = ( $path =~ /{([\w\d]+)}/g ); foreach my $param (@params) { die "Parameter $param is not defined." unless exists $parameters->{$param}; my $type = $parameters->{$param}->{schema}->{type}; $path =~ s/{$param}/$TYPE_REGEX{$type}/; } DW::Routing->register_api_rest_endpoint( $path . '$', "_dispatcher", $info, version => $ver ); } # A generic API method dispatcher, for use in registering API # endpoints to the routing table. When called, it validates credentials # and parameters, and if successful, looks up the handler # defined in the resource object for that HTTP action and calls it # or returns an error response if it's not implemented. sub _dispatcher { my ( $self, $callinfo, @path_args ) = @_; my ( $ok, $rv ) = controller( anonymous => 1 ); return $rv unless $ok; my $r = $rv->{r}; my $keystr = $r->header_in('Authorization'); my $apikey; if ( defined $keystr ) { $keystr =~ s/Bearer (\w+)/$1/; $apikey = DW::API::Key->get_key($keystr); } # all paths require an API key except the spec (which informs users that they need # a key and where to put it) unless ( defined($apikey) || $self->{path}{name} eq "/spec" ) { $r->print( to_json( { success => 0, error => "Missing or invalid API key" } ) ); $r->status('401'); return; } # match path parameters to their names my $path = $self->{path}{name}; my $path_params = {}; my @path_names = ( $path =~ /{([\w]+)}/g ); for ( my $i = 0 ; $i < @path_names ; $i++ ) { $path_params->{ $path_names[$i] } = $path_args[$i]; } my $args = {}; $args->{user} = $apikey->{user} if $apikey; # check path-level parameters. for my $param ( keys %{ $self->{path}{params} } ) { my $valid = _validate_param( $param, $self->{path}{params}{$param}, $r, $path_params, $args ); return unless $valid; } my $method = lc $r->method; my $handler = $self->{path}{methods}->{$method}->{handler}; my $method_self = $self->{path}{methods}->{$method}; # check method-level parameters for my $param ( keys %{ $method_self->{params} } ) { my $valid = _validate_param( $param, $method_self->{params}{$param}, $r, undef, $args ); return unless $valid; } # if we accept a request body, validate that too. if ( defined $method_self->{requestBody} ) { my $valid = _validate_body( $method_self->{requestBody}, $r, $args ); return unless $valid; } # some handlers need to know what version they are $method_self->{ver} = $self->{ver}; if ( defined $handler ) { return $handler->( $method_self, $args ); } else { # Generic response for unimplemented API methods. $r->print( to_json( { success => 0, error => "Not Implemented" } ) ); $r->status('501'); return $r->OK; } } # Usage: _validate_param (param, param config, request, path params, arg object) # Helper function to provide formatting and validation of parameters # Will return an error message to user on error, or update the given arg # hash on success. # NOTE query/header/cookie params are not well-tested yet # so if you're trying to implement an api route that uses them # and weird things are happening, it may be this, not you. sub _validate_param { my ( $param, $config, $r, $path_params, $arg_obj ) = @_; my $ploc = $config->{in}; my $preq = $config->{required}; my $pval = $config->{validator}; my $p; if ( $ploc eq 'query' ) { $p = $r->{get_args}{$param}; } elsif ( $ploc eq 'header' ) { $p = $r->header_in($param); } elsif ( $ploc eq 'cookie' ) { $p = $r->cookie($param); } elsif ( $ploc eq 'path' ) { $p = $path_params->{$param}; } # make sure that required parameters are supplied if ($preq) { unless ( defined $p ) { $r->print( to_json( { success => 0, error => "Missing required parameter $param" } ) ); $r->status('400'); return 0; } } # non-required parameters may be undef without it being an error # but we shouldn't try to validate them if they're undef. return 1 unless ( defined $p ); # run the schema validator my @errors = $pval->validate($p); if (@errors) { my $err_str = join( ', ', map { $_->{message} } @errors ); $r->print( to_json( { success => 0, error => "Bad format for $param. Errors: $err_str" } ) ); $r->status('400'); return 0; } $arg_obj->{$ploc}{$param} = $p; return 1; } # Usage: _validate_body (requestBody config, request, arg object) # Helper function to provide formatting and validation of request bodies # Will return an error message to user on error, or update the given arg # hash on success. # NOTE requestBody params are not well-tested yet # so if you're trying to implement an api route that uses them # and weird things are happening, it may be this, not you. sub _validate_body { my ( $config, $r, $arg_obj ) = @_; my $preq = $config->{required}; my $content_type = lc $r->header_in('Content-Type'); $content_type =~ s/;.*//; # drop data that isn't the MIMEtype my $p; if ( $content_type eq 'application/json' ) { $p = $r->json; } elsif ( $content_type eq 'application/x-www-form-urlencoded' ) { $p = $r->post_args; } elsif ( $content_type eq 'application/octet-stream' ) { # TODO: CHICKEN: IMPLEMENT die "not implemented yet\n"; } elsif ( $content_type eq 'multipart/form-data' ) { # uploads are an array of hashrefs, so we convert to Hash::MultiValue for simplicty my @uploads = $r->uploads; my $upload_hash = Hash::MultiValue->new(); for my $item (@uploads) { $upload_hash->add( $item->{name} => $item->{body} ); } $p = $upload_hash; } else { warn "Unexpected content-type $content_type"; } # make sure that required parameters are supplied if ($preq) { unless ( defined $p ) { $r->print( to_json( { success => 0, error => "Missing or badly formatted request!" } ) ); $r->status('400'); return 0; } } # non-required parameters may be undef without it being an error # but we shouldn't try to validate them if they're undef. #return 1 unless ( defined $p && defined($config->{content}->{$content_type}{validator})); # run the schema validator my @errors = $config->{content}{$content_type}{validator}->validate($p); if (@errors) { my $err_str = join( ', ', map { $_->{message} } @errors ); $r->print( to_json( { success => 0, error => "Bad format for request body. Errors: $err_str" } ) ); $r->status('400'); return 0; } $arg_obj->{body} = $p; return 1; } # Usage: schema ($object_ref) # Validates a JSON Schema attached to an object, and adds a validator # for that schema to the object. Used at multiple levels of API defs, # which is why it's in this package. sub schema { my ($self) = @_; if ( defined $self->{schema} ) { # Make sure we've been provided a valid schema to validate against my @errors = validate_json( $self->{schema}, 'http://json-schema.org/draft-07/schema#' ); croak "Invalid schema! Errors: @errors" if @errors; # make a validator against the schema my $validator = JSON::Validator->new->schema( $self->{schema} ); # turn on coercion for params, because perl doesn't care about scalar types but JSON does # so we're more flexible on input than output if ( ref($self) eq 'DW::API::Parameter' ) { $validator = $validator->coerce( { 'booleans' => 1, 'numbers' => 1, 'strings' => 1 } ); } $self->{validator} = $validator; } else { croak "No schema defined!"; } } # Formatter method for the JSON package to output resource objects as JSON. sub TO_JSON { my $self = $_[0]; my $json = {}; if ( defined $self->{path}{params} ) { $json->{parameters} = [ values %{ $self->{path}{params} } ]; } for my $key ( keys %{ $self->{path}{methods} } ) { $json->{ lc $key } = $self->{path}{methods}{$key}; } return $json; } sub params { my $self = $_[0]; my $parameters = [ values %{ $self->{path}{params} } ]; return $parameters; } sub methods { my $self = $_[0]; my $methods = $self->{path}{methods}; return $methods; } sub to_template { my $self = $_[0]; my $parameters = [ values %{ $self->{path}{params} } ]; my $methods = $self->{path}{methods}; my $vars = { params => $parameters, methods => $methods }; return DW::Template->render_template( 'api/path.tt', $vars, { no_sitescheme => 1 } ); } DW::Routing->register_string( '/api', \&api_handler, app => 1 ); DW::Routing->register_string( '/api/', \&api_handler, app => 1 ); sub api_handler { my ( $ok, $rv ) = controller(); return $rv unless $ok; my $r = $rv->{r}; my $u = $rv->{u}; my $remote = $rv->{remote}; my %api = %API_DOCS; my $paths = $api{1}; my $vars; $vars->{paths} = $paths; $vars->{key} = DW::API::Key->get_one($remote); return DW::Template->render_template( 'api.tt', $vars ); } DW::Routing->register_string( '/api/getkey', \&key_handler, app => 1 ); sub key_handler { my ( $ok, $rv ) = controller(); return $rv unless $ok; my $r = $rv->{r}; my $remote = $rv->{remote}; my $key = DW::API::Key->get_one($remote); $r->status(200); $r->content_type('text/plain; charset=utf-8'); $r->print( $key->{keyhash} ); return $r->OK; } DW::Routing->register_string( '/internal/api/404', \&api_404_handler, app => 1 ); sub api_404_handler { my ( $ok, $rv ) = controller( anonymous => 1 ); return $rv unless $ok; my $r = $rv->{r}; $r->status(404); $r->content_type('application/json; charset=utf-8'); $r->print( to_json( { success => 0, error => "Not found." } ) ); return $r->OK; } 1;