File Coverage

blib/lib/AtteanX/Endpoint.pm
Criterion Covered Total %
statement 83 101 82.1
branch 0 4 0.0
condition n/a
subroutine 29 32 90.6
pod n/a
total 112 137 81.7


line stmt bran cond sub pod time code
1             # TODO: If the model supports caching roles, add headers and check for http 304
2             # TODO: Implement and support Accept-Language models (port from RDF::Trine::Store::LanguagePreference)
3             # TODO: Add next/prev link headers if query is paged
4             # TODO: Add configuration and link headers to indicate LDF/SPARQL mirrors
5              
6 2     2   1585816 use v5.14;
  2         13  
7 2     2   10 use warnings;
  2         3  
  2         128  
8              
9             package AtteanX::Endpoint {
10             our $VERSION = "0.001_01";
11             }
12              
13             package AtteanX::Error {
14 2     2   345 use Moo;
  2         7930  
  2         9  
15 2     2   1625 use Types::Standard qw(Str HashRef);
  2         56061  
  2         17  
16 2     2   1447 use namespace::clean;
  2         7882  
  2         10  
17              
18             has 'message' => (is => 'ro', isa => Str, required => 1);
19             has 'details' => (is => 'ro', isa => HashRef, default => sub { +{} });
20             has 'uri' => (is => 'ro', isa => Str);
21             }
22              
23             package AtteanX::Endpoint::Error {
24 2     2   1184 use Moo;
  2         4  
  2         8  
25             extends 'AtteanX::Error';
26 2     2   543 use Types::Standard qw(Int);
  2         4  
  2         10  
27 2     2   811 use namespace::clean;
  2         15  
  2         10  
28             has 'code' => (is => 'ro', isa => Int, required => 1);
29             }
30              
31             package AtteanX::Endpoint::ClientError {
32 2     2   960 use Moo;
  2         11  
  2         7  
33             extends 'AtteanX::Endpoint::Error';
34 2     2   538 use Types::Standard qw(Int);
  2         4  
  2         7  
35 2     2   759 use namespace::clean;
  2         3  
  2         7  
36              
37             has 'code' => (is => 'ro', isa => Int, default => 400);
38             }
39              
40             package AtteanX::Endpoint::ServerError {
41 2     2   904 use Moo;
  2         3  
  2         8  
42             extends 'AtteanX::Endpoint::Error';
43 2     2   547 use Types::Standard qw(Int);
  2         4  
  2         7  
44 2     2   755 use namespace::clean;
  2         4  
  2         7  
45              
46             has 'code' => (is => 'ro', isa => Int, default => 500);
47             }
48              
49             package Plack::App::AtteanX::Endpoint 0.001 {
50 2     2   1187 use parent qw(Plack::Component);
  2         223  
  2         12  
51 2     2   11133 use Plack::Request;
  2         85555  
  2         375  
52            
53             sub configure {
54 0     0     my $self = shift;
55 0           $self->{config} = shift;
56 0           return $self;
57             }
58            
59             sub prepare_app {
60 0     0     my $self = shift;
61 0           my $config = $self->{config};
62 0           $self->{endpoint} = eval { AtteanX::Endpoint->new( $config ) };
  0            
63 0 0         if ($@) {
64 0           warn $@;
65             }
66             }
67              
68             sub call {
69 0     0     my($self, $env) = @_;
70 0           my $req = Plack::Request->new($env);
71 0 0         unless ($req->method =~ /^(GET|HEAD|POST)$/) {
72 0           return [ 405, [ 'Content-type', 'text/plain' ], [ 'Method not allowed' ] ];
73             }
74              
75 0           my $ep = $self->{endpoint};
76 0           my $resp = $ep->run( $req );
77 0           return $resp->finalize;
78             }
79             }
80              
81             =head1 NAME
82              
83             AtteanX::Endpoint - SPARQL 1.1 Protocol Endpoint
84              
85             =head1 VERSION
86              
87             This document describes AtteanX::Endpoint version 0.001_01
88              
89             =head1 SYNOPSIS
90              
91             use v5.14;
92             use Attean;
93              
94             =head1 DESCRIPTION
95              
96             The AtteanX::Endpoint class implements a PSGI SPARQL Protocol endpoint.
97              
98             =head1 ATTRIBUTES
99              
100             =over 4
101              
102             =item C<< planner >>
103              
104             =item C<< model >>
105              
106             =item C<< conf >>
107              
108             A hash reference containing configuration data for the endpoint. For example:
109              
110             {
111             endpoint => {
112             service_description => {
113             named_graphs => 1,
114             default => 1,
115             },
116             html => {
117             embed_images => 1,
118             image_width => 200,
119             resource_links => 1,
120             },
121             load_data => 0,
122             update => 0,
123             }
124             }
125              
126             =item C<< graph >>
127              
128             The L<Attean::API::IRI> of the graph in the model that represents the default graph.
129              
130             =back
131              
132             =head1 METHODS
133              
134             =over 4
135              
136             =cut
137              
138             package AtteanX::Endpoint {
139 2     2   4 use Moo;
  2         14  
140 2     2   941 use Attean;
  2         1383383  
  2         10  
141 2     2   636 use TryCatch;
  2         2047319  
  2         10  
142 2     2   1430 use JSON;
  2         10966  
  2         10  
143 2     2   195 use Encode;
  2         5  
  2         132  
144 2     2   10 use Plack::Request;
  2         6  
  2         47  
145 2     2   454 use Plack::Response;
  2         2520  
  2         59  
146 2     2   11 use Scalar::Util qw(blessed refaddr);
  2         3  
  2         95  
147 2     2   11 use List::MoreUtils qw(any);
  2         3  
  2         17  
148 2     2   1449 use File::ShareDir qw(dist_dir);
  2         4  
  2         99  
149 2     2   13 use HTTP::Negotiate qw(choose);
  2         3  
  2         78  
150 2     2   652 use IO::Compress::Gzip qw(gzip);
  2         46132  
  2         164  
151 2     2   602 use HTML::HTML5::Writer qw(DOCTYPE_XHTML_RDFA);
  0            
  0            
152             use Carp qw(croak);
153             use Types::Standard qw(ConsumerOf CodeRef HashRef ArrayRef Str Int);
154             # use IO::Handle;
155             # use Digest::MD5 qw(md5_base64);
156             use XML::LibXML 1.70;
157             # use RDF::RDFa::Generator 0.102;
158             # use Hash::Merge::Simple qw/ merge /;
159             # use Fcntl qw(:flock SEEK_END);
160             use namespace::clean;
161              
162             with 'MooX::Log::Any';
163              
164             has 'planner' => (
165             is => 'ro',
166             isa => ConsumerOf['Attean::API::QueryPlanner'],
167             required => 1,
168             default => sub {
169             Attean::IDPQueryPlanner->new();
170             }
171             );
172             has 'model' => (is => 'ro', isa => ConsumerOf['Attean::API::Model'], required => 1);
173             has 'conf' => (is => 'ro', isa => HashRef, required => 1);
174             has 'graph' => (is => 'ro', isa => ConsumerOf['Attean::API::IRI'], required => 1);
175            
176             sub BUILDARGS {
177             my $class = shift;
178             my @params = @_;
179             my %args;
180             if (blessed($params[0]) and $params[0]->does('Attean::API::Model')) {
181             # ->new( $model, \%conf )
182             $args{ model } = shift @params;
183             $args{ conf } = shift @params;
184             $args{ graph } = Attean::IRI->new('http://example.org/graph');
185             } elsif (any { blessed($_) && $_->does('Attean::API::Model') } @params) {
186             # Assume the buildargs can be taken directly
187             return $class->SUPER::BUILDARGS(@params);
188             } else {
189             # ->new( \%conf )
190             my $conf = shift @params;
191             my $store_conf = $conf->{store};
192             my ($name, $file) = split(';', $store_conf, 2);
193             my $sclass = Attean->get_store($name)->new();
194             my $store = $sclass->new();
195             my $model = Attean::MutableQuadModel->new( store => $store );
196            
197             my $graph = Attean::IRI->new('http://example.org/graph');
198             if (defined($file) and length($file)) {
199             $graph = Attean::IRI->new('file://' . File::Spec->rel2abs($file));
200             open(my $fh, '<:encoding(UTF-8)', $file) or die $!;
201             #$self->log->debug("Parsing data from $file...");
202             my $pclass = Attean->get_parser( filename => $file ) // 'AtteanX::Parser::Turtle';
203             my $parser = $pclass->new(base => $graph);
204             my $iter = $parser->parse_iter_from_io($fh);
205             my $quads = $iter->as_quads($graph);
206             $model->add_iter($quads);
207             }
208            
209             $args{ model } = $model;
210             $args{ conf } = $conf;
211             $args{ graph } = $graph;
212             }
213            
214             return $class->SUPER::BUILDARGS(%args);
215             }
216              
217             =item C<< run ( $request ) >>
218              
219             Run the SPARQL request contained in the given C<< $request >> object and return
220             a response object.
221              
222             =cut
223            
224             sub run {
225             my $self = shift;
226             my $req = shift;
227             try {
228             return $self->_run($req, @_);
229             }
230             catch (AtteanX::Endpoint::Error $e) {
231             my $resp = Plack::Response->new;
232             my $code = $e->code;
233             my $status = $e->message;
234             my $error = {
235             title => $status,
236             describedby => $e->uri,
237             };
238             if (my $d = $e->details) {
239             $error->{details} = $d;
240             }
241             my @variants = (
242             ['text/plain', 0.98, 'text/plain'],
243             ['application/json-problem', 0.99, 'application/json-problem'],
244             );
245             my $headers = $req->headers;
246             my $stype = choose( \@variants, $headers ) || 'text/plain';
247             if ($stype eq 'application/json-problem') {
248             $resp->headers->content_type( 'application/json-problem' );
249             $resp->status($code);
250             my $content = encode_json($error);
251             $resp->body($content);
252             } else {
253             $resp->headers->content_type( 'text/plain' );
254             $resp->status($code);
255             my @messages = grep { defined($_) } @{ $error }{ qw(title detail) };
256             my $content = join("\n\n", $status, @messages);
257             $resp->body($content);
258             }
259             return $resp;
260             }
261             }
262            
263             sub _run {
264             my $self = shift;
265             my $req = shift;
266            
267             my $config = $self->{conf};
268             my $endpoint_path = $config->{endpoint}{endpoint_path} || '/sparql';
269             my $model = $self->{model};
270            
271             my $response = Plack::Response->new;
272              
273             our $VERSION;
274             my $server = "AtteanX::Endpoint/$VERSION";
275             $server .= " " . $response->headers->header('Server') if defined($response->headers->header('Server'));
276             $response->headers->header('Server' => $server);
277              
278             unless ($req->path eq $endpoint_path) {
279             my $content;
280             my $path = $req->path_info;
281             $path =~ s#^/##;
282             my $dir = $ENV{ATTEAN_ENDPOINT_SHAREDIR} || File::Spec->catdir((eval { dist_dir('AtteanX-Endpoint') } || 'share'), 'endpoint');
283             my $abs = File::Spec->rel2abs($dir);
284             my $file = File::Spec->catfile($abs, 'www', $path);
285             if (-r $file) {
286             open( my $fh, '<', $file ) or croak $!;
287             $response->status(200);
288             $content = $fh;
289             } else {
290             my $path = $req->path;
291             $response->status(404);
292             $content = <<"END";
293             <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">\n<html><head>\n<title>404 Not Found</title>\n</head><body>\n
294             <h1>Not Found</h1>\n<p>The requested URL $path was not found on this server.</p>\n</body></html>
295             END
296             }
297             $response->body($content);
298             return $response;
299             }
300            
301             my $headers = $req->headers;
302             my $type = $headers->header('Accept') || 'application/sparql-results+xml';
303             if (my $t = $req->param('media-type')) {
304             $type = $t;
305             $headers->header('Accept' => $type);
306             }
307            
308             my $ae = $req->headers->header('Accept-Encoding') || '';
309            
310             my $sparql;
311             my $content;
312             my $ct = $req->header('Content-type');
313             if ($req->method !~ /^(GET|POST)$/i) {
314             my $method = uc($req->method);
315             $content = "Unexpected method $method (expecting GET or POST)";
316             $self->log_error( $req, $content );
317             $response->header('Allow' => 'GET, POST');
318             die AtteanX::Endpoint::ClientError->new(code => 405, message => 'Method not allowed', uri => 'http://id.kasei.us/rdf-endpoint/error/bad_http_method');
319             } elsif (defined($ct) and $ct eq 'application/sparql-query') {
320             $sparql = $req->content;
321             } elsif (defined($ct) and $ct eq 'application/sparql-update') {
322             if ($config->{endpoint}{update} and $req->method eq 'POST') {
323             $sparql = $req->content;
324             }
325             } elsif ($req->param('query')) {
326             my @sparql = $req->param('query');
327             if (scalar(@sparql) > 1) {
328             $content = "More than one query string submitted";
329             $self->log_error( $req, $content );
330             die AtteanX::Endpoint::ClientError->new(code => 400, message => 'Multiple query strings not allowed', uri => 'http://id.kasei.us/rdf-endpoint/error/multiple_queries');
331             } else {
332             $sparql = $sparql[0];
333             }
334             } elsif ($req->param('update')) {
335             my @sparql = $req->param('update');
336             if (scalar(@sparql) > 1) {
337             $content = "More than one update string submitted";
338             $self->log_error( $req, $content );
339             die AtteanX::Endpoint::ClientError->new(code => 400, message => 'Multiple update strings not allowed', uri => 'http://id.kasei.us/rdf-endpoint/error/multiple_updates');
340             }
341            
342             if ($config->{endpoint}{update} and $req->method eq 'POST') {
343             $sparql = $sparql[0];
344             } elsif ($req->method ne 'POST') {
345             my $method = $req->method;
346             $content = "Update operations must use POST";
347             $self->log_error( $req, $content );
348             $response->header('Allow' => 'POST');
349             die AtteanX::Endpoint::ClientError->new(code => 405, message => "$method Not Allowed for Update Operation", uri => 'http://id.kasei.us/rdf-endpoint/error/bad_http_method_update');
350             }
351             }
352            
353             if ($sparql) {
354             my %args;
355             $args{ update } = 1 if ($config->{endpoint}{update} and $req->method eq 'POST');
356             $args{ load_data } = 1 if ($config->{endpoint}{load_data});
357            
358             my $protocol_specifies_update_dataset = 0;
359             {
360             my @default = $req->param('default-graph-uri');
361             my @named = $req->param('named-graph-uri');
362             if (scalar(@default) or scalar(@named)) {
363             delete $args{ load_data };
364             # TODO: handle custom-dataset
365             $self->log->warn('custom query datasets not supported yet');
366             # $model = Attean::MutableQuadModel->new( store => Attean->get_store('Memory')->new() );
367             # foreach my $url (@named) {
368             # RDF::Trine::Parser->parse_url_into_model( $url, $model, context => iri($url) );
369             # }
370             # foreach my $url (@default) {
371             # RDF::Trine::Parser->parse_url_into_model( $url, $model );
372             # }
373             }
374             }
375            
376             {
377             my @default = $req->param('using-graph-uri');
378             my @named = $req->param('using-named-graph-uri');
379             if (scalar(@named) or scalar(@default)) {
380             $protocol_specifies_update_dataset = 1;
381             # TODO: handle custom-dataset
382             $self->log->warn('custom update datasets not supported yet');
383             # $model = RDF::Trine::Model::Dataset->new( $model );
384             # $model->push_dataset( default => \@default, named => \@named );
385             }
386             }
387            
388             # my $match = $headers->header('if-none-match') || '';
389             # my $etag = md5_base64( join('#', $self->run_tag, $model->etag, $type, $ae, $sparql) );
390             # if (length($match)) {
391             # if (defined($etag) and ($etag eq $match)) {
392             # $response->status(304);
393             # return $response;
394             # }
395             # }
396            
397             my $base = $req->base;
398             my $parser = Attean->get_parser('SPARQL')->new(base => $base);
399             $parser->update(1) if ($args{update});
400             my ($algebra) = eval { $args{update} ? $parser->parse_update($sparql, base => $base) : $parser->parse($sparql, base => $base) };
401             if ($@ or not($algebra)) {
402             my $error = $@ || 'Internal error';
403             $self->log_error( $req, $error );
404             my $eclass = ($error =~ /Syntax/) ? 'AtteanX::Endpoint::ClientError' : 'AtteanX::Endpoint::ServerError';
405             if ($req->method ne 'POST' and $error =~ /read-only queries/sm) {
406             $error = 'Updates must use a HTTP POST request.';
407             die $eclass->new(message => 'Updates must use a HTTP POST request', uri => 'http://id.kasei.us/rdf-endpoint/error/bad_http_method_update');
408             } else {
409             die $eclass->new(message => 'SPARQL query/update parse error', uri => 'http://id.kasei.us/rdf-endpoint/error/parse_error', details => { error => $error, sparql => $sparql });
410             }
411             } else {
412             $self->log_query( $req, $sparql );
413             # TODO: handle case where query specifies update dataset
414             # if ($protocol_specifies_update_dataset and $query->specifies_update_dataset) {
415             # my $method = $req->method;
416             # $content = "Update operations cannot specify a dataset in both the query and with protocol parameters";
417             # $self->log_error( $req, $content );
418             # die AtteanX::Endpoint::ClientError->new(code => 400, message => 'Multiple datasets specified for update', uri => 'http://id.kasei.us/rdf-endpoint/error/update_specifies_multiple_datasets');
419             # }
420             if ($self->log->is_trace) {
421             $self->log->trace("Algebra:\n" . $algebra->as_string);
422             }
423             my $graph = $self->graph;
424             my $default_graphs = [$graph];
425             my $planner = $self->planner;
426             if ($self->log->is_trace) {
427             $self->log->debug('Planning with default graphs:');
428             foreach my $g (@$default_graphs) {
429             $self->log->trace($g->as_string);
430             }
431             }
432             my $plan = $planner->plan_for_algebra($algebra, $model, $default_graphs);
433             if ($self->log->is_debug) {
434             $self->log->debug("Plan:\n" . $plan->as_string);
435             }
436             eval {
437             my $iter = $plan->evaluate($model);
438             $response->status(200);
439             my $sclass = Attean->negotiate_serializer(request_headers => $headers) // Attean->get_serializer('sparqlxml');
440             $self->log->debug("Serializer class: $sclass");
441             my $s = $sclass->new();
442             $content = $s->serialize_iter_to_bytes($iter);
443             my $stype = $s->canonical_media_type;
444             $response->headers->content_type($stype);
445             };
446             if ($@) {
447             my $error = $@;
448             $self->log->fatal($error);
449             die AtteanX::Endpoint::ServerError->new(code => 500, message => 'SPARQL query/update execution error', uri => 'http://id.kasei.us/rdf-endpoint/error/execution_error', details => { error => $@, sparql => $sparql });
450             }
451             }
452             } elsif ($req->method eq 'POST') {
453             $content = "POST without recognized query or update";
454             $self->log_error( $req, $content );
455             die AtteanX::Endpoint::ClientError->new(message => 'Missing SPARQL Query/Update String', uri => 'http://id.kasei.us/rdf-endpoint/error/missing_sparql_string');
456             } else {
457             my $stype = 'text/html';
458             my $dir = $ENV{ATTEAN_ENDPOINT_SHAREDIR} || File::Spec->catdir((eval { dist_dir('AtteanX-Endpoint') } || 'share'), 'endpoint');
459             my $template = File::Spec->catfile($dir, 'index.html');
460             my $parser = XML::LibXML->new(validation => 0, suppress_errors => 1, no_network => 1, recover => 2);
461             my $doc = $parser->parse_file( $template );
462             # my $gen = RDF::RDFa::Generator->new( style => 'HTML::Head');
463             # $gen->inject_document($doc, $sdmodel);
464            
465             my $writer = HTML::HTML5::Writer->new( markup => 'xhtml', doctype => DOCTYPE_XHTML_RDFA );
466             $content = encode_utf8( $writer->document($doc) );
467             $response->status(200);
468             $response->headers->content_type('text/html');
469             }
470            
471             $content = $response->body || $content;
472             my $length = 0;
473             my %ae = map { $_ => 1 } split(/\s*,\s*/, $ae);
474             if ($ae{'gzip'}) {
475             my $orig = length($content);
476             my ($rh, $wh);
477             pipe($rh, $wh);
478             if (ref($content)) {
479             gzip $content => $wh;
480             } else {
481             gzip \$content => $wh;
482             }
483             close($wh);
484             my $body = do { local($/) = undef; <$rh> };
485             $self->log->info("Compressed $orig bytes to " . length($body) . " bytes");
486             $length = bytes::length($body);
487             $response->headers->header('Content-Encoding' => 'gzip');
488             $response->headers->header('Content-Length' => $length);
489             $response->body( $body ) unless ($req->method eq 'HEAD');
490             } else {
491             local($/) = undef;
492             my $body = ref($content) ? <$content> : $content;
493             $length = bytes::length($body);
494             $response->headers->header('Content-Length' => $length);
495             $response->body( $body ) unless ($req->method eq 'HEAD');
496             }
497             return $response;
498             }
499            
500             =item C<< log_query ( $request, $sparql ) >>
501              
502             Log the C<< $sparql >> query string after having been parsed from the
503             C<< $request >> but before evaluation.
504              
505             =cut
506              
507             sub log_query {
508             my $self = shift;
509             my $req = shift;
510             my $message = shift;
511             $self->log->info("SPARQL query:\n" . $message);
512             $self->_log( $req, { level => 'info', message => $message } );
513             }
514              
515             =item C<< log_error ( $message ) >>
516              
517             =cut
518              
519             sub log_error {
520             my $self = shift;
521             my $req = shift;
522             my $message = shift;
523             $self->log->error($message);
524             $self->_log( $req, { level => 'error', message => $message } );
525             }
526              
527             sub _log {
528             my $self = shift;
529             my $req = shift;
530             my $data = shift;
531             my $logger = $req->logger || sub {};
532            
533             $logger->($data);
534             }
535              
536             sub _set_response_error {
537             my $self = shift;
538             my $req = shift;
539             my $resp = shift;
540             my $code = shift;
541             my $error = shift;
542             my @variants = (
543             ['text/plain', 1.0, 'text/plain'],
544             ['application/json-problem', 0.99, 'application/json-problem'],
545             );
546             my $headers = $req->headers;
547             my $stype = choose( \@variants, $headers ) || 'text/plain';
548             if ($stype eq 'application/json-problem') {
549             $resp->headers->content_type( 'application/json-problem' );
550             $resp->status($code);
551             my $content = encode_json($error);
552             $resp->body($content);
553             } else {
554             $resp->headers->content_type( 'text/plain' );
555             $resp->status($code);
556             my @messages = grep { defined($_) } @{ $error }{ qw(title detail) };
557             my $content = join("\n\n", @messages);
558             $resp->body($content);
559             }
560             return;
561             }
562             }
563              
564             1;
565              
566             __END__
567              
568             =back
569              
570             =head1 BUGS
571              
572             Please report any bugs or feature requests to through the GitHub web interface
573             at L<https://github.com/kasei/atteanx-endpoint/issues>.
574              
575             =head1 SEE ALSO
576              
577             L<http://www.perlrdf.org/>
578              
579             =head1 AUTHOR
580              
581             Gregory Todd Williams C<< <gwilliams@cpan.org> >>
582              
583             =head1 COPYRIGHT
584              
585             Copyright (c) 2016 Gregory Todd Williams.
586             This program is free software; you can redistribute it and/or modify it under
587             the same terms as Perl itself.
588              
589             =cut