mourningdove/cgi-bin/DW/Controller/API/REST.pm
2026-05-24 01:03:05 +00:00

458 lines
14 KiB
Perl

#!/usr/bin/perl
#
# DW::Controller::API::REST
#
# REST API.
#
# Authors:
# Allen Petersen <allen@suberic.net>
#
# 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;