Terse/lib/Terse.pm
package Terse;
our $VERSION = '0.27';
use 5.006;
use strict;
use warnings;
no warnings 'redefine';
use Plack::Request;
use Plack::Response;
use Cpanel::JSON::XS;
use Scalar::Util qw/reftype/;
use Time::HiRes qw(gettimeofday);
use Terse::WebSocket;
use Want qw/want/;
use Digest::SHA;
use URI;
use Struct::WOP qw/all/ => { type => ['UTF-8'], destruct => 1 };
our ($JSON, %PRIVATE);
BEGIN {
$JSON = Cpanel::JSON::XS->new->utf8->canonical(1)->allow_blessed->convert_blessed;
%PRIVATE = (
map { $_ => 1 }
qw/new run logger logInfo logError websocket delayed_response build_terse content_type raiseError graft pretty serialize DESTROY TO_JSON AUTOLOAD to_app/
);
}
sub new {
my ($pkg, %args) = @_;
$pkg = ref $pkg if ref $pkg;
if (delete $args{private}) {
for my $key (keys %args) {
if ($key !~ m/^_/) {
$args{"_$key"} = delete $args{$key};
}
}
}
return bless \%args, $pkg;
}
sub run {
my ($pkg, %args) = @_;
my $j = $pkg->new(
private => 1,
login => 'login',
logout => 'logout',
auth => 'auth',
insecure_session => 0,
content_type => 'application/json',
request_class => 'Plack::Request',
websocket_class => 'Terse::WebSocket',
sock => 'psgix.io',
stream_check => 'psgi.streaming',
favicon => 'favicon.ico',
%args
);
$j->headers = {};
$j->_build_terse();
$j->request = $j->{_request_class}->new($args{plack_env});
$j->response = $pkg->new(
authenticated => \0,
error => \0,
errors => [],
);
if ($j->request->env->{PATH_INFO} =~ m/favicon.ico$/) {
return [500, [], []] unless -f $j->_favicon;
open my $fh, '<', $j->_favicon;
my $favicon = do { local $/; <$fh> };
close $fh;
return [200, ['Content-Type', 'image/vnd.microsoft.icon'], [$favicon] ];
}
my $content_type = $j->request->content_type;
if ($content_type && $content_type =~ m/application\/json/) {
$j->graft('params', $j->request->raw_body || "{}");
} else {
$j->params = {%{$j->request->parameters || {}}};
}
unless ((reftype($j->params) || "") eq 'HASH') {
$j->response->raiseError('Invalid parameters', 400);
return $j->_response($j->response);
}
$j->sid = $j->request->cookies->{sid};
unless ($j->sid) {
my $h = Digest::SHA->new(256);
my @us = gettimeofday;
push @us, map { $j->request->env->{$_} } grep {
$_ =~ /^HTTP(?:_|$)/;
} keys %{ $j->request->env };
$h->add(@us);
$j->sid = $h->hexdigest;
}
$j->sid = {
value => $j->is_logout ? "" : $j->sid,
path => $j->{_sid_path} || $j->{_root_path} || "/",
secure => !$j->{_insecure_session},
samesite => 'none'
};
my $auth = $j->{_auth};
my ($session) = $j->_dispatch($auth, $pkg->new());
my $req = $j->params->req;
$req =~ /^([a-z][0-9a-zA-Z_]{1,31})$/ && do { $req = $1 // '' } if $req;
$req = $j->{_application}->preprocess_req($req, $j) if $j->{_application}->can('preprocess_req');
if (!$req || !$session || $PRIVATE{$req}) {
$j->response->raiseError('Invalid request', 400);
return $j->_response($j->response);
}
$j->req = $req;
$j->response->authenticated = \1;
$j->session = $session;
$j->sid->expires = (ref $j->session && $j->session->expires) || (time + 24 * 60 * 60)
if (!$j->sid->expires);
($j->is_login, $j->is_logout) = (
$j->{_login} eq $req,
$j->{_logout} eq $req
);
my ($out) = $j->_dispatch($req);
return $j->_response($j->response) if $j->response->error;
$j->session = $out if ( $j->is_login || $j->is_logout );
($j->session) = $j->_dispatch($auth, $j->session) if $j->response->authenticated;
if ((!$j->response->authenticated || !$j->session) && !($j->is_login || $j->is_logout)) {
$j->response->raiseError('Unauthenticated during the request', 400);
return $j->_response($j->response);
}
return $j->_response($j->response, $j->sid, $j->content_type);
}
sub to_app {
my ($self, $new, $run) = @_;
my $app = $self->new($new ? %{ $new } : ());
return sub {
my ($env) = (shift);
Terse->run(
plack_env => $env,
application => $app,
($env->{'psgix.logger'} ? (logger => $env->{'psgix.logger'}) : ()),
);
};
};
sub logger {
my ($self, $logger) = @_;
$self->{_logger} = $logger if ($logger);
return $self->{_logger};
}
sub logError {
my ($self, $message, $status, $no_response) = @_;
$self->{_application}
? $self->response->raiseError($message, $status)
: $self->raiseError($message, $status);
$message = { message => $message } if (!ref $message);
$message = $self->{_application}->_logError($message, $status)
if ($self->{_application} && $self->{_application}->can('_logError'));
ref $self->{_logger} eq 'CODE'
? $self->{_logger}->('error', $message)
: $self->{_logger}->error($message)
if $self->{_logger};
$self->response->no_response = 1 if $no_response;
return $self;
}
sub logInfo {
my ($self, $message) = @_;
$message = { message => $message } if (!ref $message);
$message = $self->{_application}->_logInfo($message)
if ($self->{_application} && $self->{_application}->can('_logInfo'));
ref $self->{_logger} eq 'CODE'
? $self->{_logger}->('info', $message)
: $self->{_logger}->info($message)
if $self->{_logger};
return $self;
}
sub raiseError {
my ($self, $message, $code) = @_;
return $self->response->raiseError($message, $code) if $self->{_application};
$self->{error} = \1;
if ((reftype($message) || '') eq 'ARRAY') {
push @{$self->{errors}}, @{$message};
} else {
push @{$self->{errors}}, $message;
}
$self->{status_code} = $code if ($code && !$self->{status_code});
return $self;
}
sub graft {
my ($self, $name, $json) = @_;
unless ($json =~ m/[\{\[]/) {
$self->{$name} = $json;
return $self->{$name};
}
$self->{$name} = eval {
$JSON->decode($json);
};
return 0 if $@;
return $self->_bless_tree($self->{$name});
}
sub pretty { $_[0]->{_pretty} = 1; $_[0]; }
sub serialize {
my ($self, $die) = @_;
my $pretty = !!(reftype $self eq 'HASH' && $self->{_pretty});
my $out = eval {
$JSON->pretty($pretty)->encode(maybe_decode($self));
};
die $@ if ($@ && $die);
return $out || $@;
}
sub _build_terse {
my ($t) = @_;
if (! $t->{_application}) {
$t->response->raiseError('No application passed to run', 500);
return $t->_response($t->response);
}
$t->{redirect} = sub {
my ($self, $url, $response) = @_;
$url = URI->new($url);
$url->query_form( $url->query_form, %{$response || {}});
$self->response->status_code = 302;
$self->response->message = 'Found';
$self->headers->Location = $url->as_string;
return $self;
};
$t->{websocket} = sub {
my ($self, %args) = @_;
my $websocket = $t->{_websocket_class}->new($self);
if (!ref $websocket) {
$args{error}->($t, $websocket);
return;
}
$t->{_delayed_response} = sub {
my $responder = shift;
$websocket->start($t, \%args, $responder);
};
return $websocket;
} unless $t->{websocket} || !$t->{_websocket_class};
$t->{delayed_response} = sub {
my ($self, $response, $sid, $ct, $status) = @_;
$sid ||= $self->sid;
$status ||= 200;
$ct ||= 'application/json';
return $self->{_application}->delayed_response_handle(
$self, $response, $sid, $ct, $status
) if $self->{_application_has_delayed_response_handler};
$self->{_delayed_response} = sub {
my $responder = shift;
my $res = $self->_build_response($sid, $ct, $status);
$res = [splice @{$res->finalize}, 0, 2];
my $writer = $responder->($res);
$response = eval { $response->($writer); };
if ($@ || $self->response->error) {
$res->[0] = $self->response->status_code || 500;
$self->raiseError($@) if $@;
push @{$res}, [$self->response->serialize];
return $responder->($res);
}
elsif ($response) {
$writer->write($response->serialize);
}
$writer->close;
};
$self;
} unless $t->{delayed_response};
$t->{_application}->build_terse($t) if $t->{_application}->can('build_terse');
$t->{_application_has_dispatcher} = !! $t->{_application}->can('dispatch');
$t->{_application_has_response_handler} = !! $t->{_application}->can('response_handle');
$t->{_application_has_delayed_response_handler} = !! $t->{_application}->can('delayed_response_handle');
$t->{_build_response} = sub {
my ($self, $sid, $content, $status) = @_;
my $res = $self->request->new_response($self->response->{status_code} ||= $status);
$res->cookies($self->cookies) if $self->cookies;
$res->headers({%{$self->headers}}) if $self->headers;
$res->cookies->{sid} = {%{$sid}} if $sid;
$res->content_type($content);
return $res;
} unless $t->{_build_response};
$t->{content_type} = sub {
$_[0]->{_content_type} = $_[1] if $_[1];
return $_[0]->{_content_type};
} unless $t->{content_type};
$t->{_response} = sub {
my ($self, $response_body, $sid, $ct, $status) = @_;
return $self->{_application}->response_handle(@_) if $self->{_application_has_response_handler};
$ct ||= 'application/json';
my $res = $self->{_delayed_response};
return $res if ($res);
$res = $self->_build_response($sid, $ct, $status || 200);
$res->body($response_body->serialize());
return $res->finalize;
} unless $t->{_response};
$t->{_dispatch} = sub {
my ($self, $method, @params) = @_;
my @out = $self->{_application_has_dispatcher} ? eval {
$self->{_application}->dispatch($method, $self, @params)
} : eval {
unless ($self->{_application}->can($method)) {
$self->response->raiseError('Invalid request - ' . $method, 400);
return;
}
$self->{_application}->$method($self, @params);
};
if ($@) {
$self->response->raiseError(['Error while dispatching the request', $@], 400);
return;
}
return @out;
} unless $t->{_dispatch};
return $t;
}
sub _bless_tree {
my ($self, $node) = @_;
my $refnode = ref $node;
return unless $refnode eq 'HASH' || $refnode eq 'ARRAY';
if ($refnode eq 'HASH'){
bless $node, $node->{_inherit} ? ref $self : __PACKAGE__;
$self->_bless_tree($node->{$_}) for keys %$node;
}
if ($refnode eq 'ARRAY'){
bless $node, ref $self;
$self->_bless_tree($_) for @$node;
}
$node;
}
sub TO_JSON {
my $self = shift;
my $ref = reftype $self;
return $self unless $ref && $ref =~ m/ARRAY|HASH/;
return [@$self] if $ref eq 'ARRAY';
return 'cannot stringify application object' if $self->{_application};
my $output = {};
my $nodebug = ! $self->{_debug};
for(keys %$self){
my $skip;
$skip++ if $_ =~ /^_/ && $nodebug;
next if $skip;
$output->{$_} = $self->{$_};
}
return $output;
}
sub DESTROY {
my ($self) = @_;
(reftype $self eq 'HASH' ? %{$self} : @{$self}) = ();
}
sub AUTOLOAD : lvalue {
my $classname = ref $_[0];
my $validname = '[_a-zA-Z][\:a-zA-Z0-9_]*';
our $AUTOLOAD =~ /^${classname}::($validname)$/;
my $key = $1;
die "illegal key name, must be of $validname form\n$AUTOLOAD" unless $key;
my $miss = Want::want('REF OBJECT') ? {} : '';
my $retval = $_[0]->{$key};
return $retval->(@_) if (ref $retval eq 'CODE');
die "illegal use of AUTOLOAD $classname -> $key - too many arguments" if (scalar @_ > 2);
my $isBool = Want::want('SCALAR BOOL') && ((reftype($retval) // '') eq 'SCALAR');
return $$retval if $isBool;
$_[0]->{$key} = $_[1] // $retval // $miss;
$_[0]->_bless_tree($_[0]->{$key}) if ref $_[0]->{$key} eq 'HASH' || ref $_[0]->{$key} eq 'ARRAY';
$_[0]->{$key};
}
1;
__END__
=head1 NAME
Terse - Lightweight Web Framework
=head1 VERSION
Version 0.27
=cut
=head1 SYNOPSIS
package MyAPI;
use base 'Terse';
sub auth {
my ($self, $t, $session) = @_;
return 0 if $t->params->not;
return $session;
}
sub hello_world {
my ($self, $t) = @_;
if ($t->params->throw_error) {
$t->logError('throw 500 error which is also logged', 500);
return;
}
$t->response->hello = "world";
}
sub delayed_hello_world {
my ($self, $t) = @_;
$t->delayed_response(sub {
if ($t->params->throw_error) {
$t->logError('throw 500 error which is also logged', 500);
return;
}
... do something which takes a long time ...
$t->response->hello = "world";
return $t->response;
});
}
sub websock {
my ($self, $t) = @_;
$t->websocket(
connect => {
my ($websocket) = @_;
$websocket->send('Hello');
},
recieve => {
my ($websocket, $message) = @_;
$websocket->send($message); # echo
},
error => { ... },
disconnect => { ... }
);
}
sub redirect {
my ($self, $t) = @_;
if ($t->params->hello) {
$t->redirect('', { req => 'hello_world' });
} else {
$t->redirect('https://world-wide.world/');
}
}
.... MyAPI.psgi ...
use Terse;
use MyAPI;
my $app = MyAPI->to_app();
....
plackup -s Starman MyAPI.psgi
GET http://localhost:5000/?req=delayed_hello_world
# {"authenticated":1,"error":false,"errors":[],"hello":"world","status_code":200}
GET http://localhost:5000/?req=hello_world¬=1
# {"authenticated":0,"error":true,"errors":["Invalid request"],"status_code":400}
GET http://localhost:5000/?req=hello_world&throw_error=1
# {"authenticated":1,"error":true,"errors":["throw 500 error which is also logged"],"status_code":500}
=cut
=head1 Description
Alot of the inspiration, and some code, for this module came from L<JSONP> - which is a module to quickly build JSON/JSONP web services, providing also some syntactic sugar acting a bit like a sort of DSL (domain specific language) for JSON. ( thanks Anselmo Canfora L<ACANFORA>! )
There are several key differences between Terse and L<JSONP>, the main being Terse uses Plack and not CGI. Terse also makes it simpler to provision the data which should be returned from the API (and what should not), finally it adds logging support.
=cut
=head1 Methods
=cut
=head2 new
Instantiate a new Terse object.
my $object = Terse->new(%params);
=cut
=head2 run
Run terse as a plack application.
Terse->run(
login => 'login',
logout => 'logout',
auth => 'auth',
insecure_session => 0,
application => Terse->new(
auth => sub { ... },
login => sub { ... },
logout => sub { ... }
),
plack_env => $env
);
The "application" does not need to be a "Terse application", the only requirment is it implements the auth, login and logout methods (go crazy!).
=cut
=head2 params
Retrieve params for the request.
$terse->params;
=cut
=head2 request
Returns the Plack::Request.
$terse->request;
=cut
=head2 session
Retrieve current session data, set in your auth or login methods
$terse->session;
=cut
=head2 response
Set the response body data.
$terse->response->foo = { ... };
=cut
=head2 logger
Set or Retrieve the logger for the application.
$terse->logger($logger);
$terse->logger->info();
$terse->logger->err();
=cut
=head2 logError
Log and raise an error message.
$terse->logError('this is an error message', 404);
=cut
=head2 logInfo
Log an info message.
$terse->logInfo('this an info message');
=cut
=head2 raiseError
Raise an error message.
$terse->raiseError('this is an error message', 404);
=cut
=head2 graft
Decode a JSON string.
$terse->response->graft('config', "{...}");
=cut
=head2 pretty
Set JSON to pretty print mode.
$terse->pretty(1);
=cut
=head2 serialize
Encode a perl struct as a JSON string.
$terse->serialize({ ... });
=cut
=head2 delayed_response
Delay the response for non-blocking I/O based server streaming or long-poll Comet push technology.
$terse->delayed_response(sub {
$terse->response->test = 'okay';
return $terse->response;
});
=cut
=head2 redirect
Redirect the response to a different url.
$terse->redirect($host_path, \%query_string);
=head1 AUTHOR
LNATION, C<< <email at lnation.org> >>
=head1 BUGS
Please report any bugs or feature requests to C<bug-terse at rt.cpan.org>, or through
the web interface at L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Terse>. I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.
=head1 SUPPORT
You can find documentation for this module with the perldoc command.
perldoc Terse
You can also look for information at:
=over 4
=item * RT: CPAN's request tracker (report bugs here)
L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Terse>
=item * CPAN Ratings
L<https://cpanratings.perl.org/d/Terse>
=item * Search CPAN
L<https://metacpan.org/release/Terse>
=back
=head1 ACKNOWLEDGEMENTS
=head1 LICENSE AND COPYRIGHT
This software is Copyright (c) 2022 by LNATION.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)
=cut
1; # End of Terse