Router.pm 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. package TPSGI::Router;
  2. # ABSTRACT: Match requests to the operations of OpenAPI 3 documents, and validate them
  3. use 5.040;
  4. use warnings FATAL => 'all';
  5. use re '/aasx';
  6. use List::Util ();
  7. use JSON::Validator::Schema::OpenAPIv3;
  8. use JSON::Validator::Util ();
  9. use Cpanel::JSON::XS ();
  10. =head1 SYNOPSIS
  11. my $router = TPSGI::Router->new( 'lib/My/Routes.pm' => \%My::Routes::openapi );
  12. sub answer {
  13. my ( $tpsgi, $method, $path, %query ) = @_;
  14. my $request = { method => $method, fullpath => $path };
  15. my $route = $router->match( $method, $path );
  16. return $tpsgi->notfound($request) unless $route;
  17. return $tpsgi->notallowed( $request, @{ $route->{allow} } ) if $route->{status};
  18. my ( $status, @errors ) = $router->validate( $route, query => \%query );
  19. return $tpsgi->badrequest($request) if $status;
  20. return $route->{callback}->( $tpsgi, { %query, %{ $route->{path} } } );
  21. }
  22. =head1 DESCRIPTION
  23. TPSGI::Router compiles the C<%openapi> document of each router once. Then it
  24. answers which operation a request is for, and whether the request is valid for
  25. that operation.
  26. Each key of C<paths> is a template. A template is a list of segments, which
  27. slashes separate. A segment is made of literal text and variables:
  28. =over
  29. =item C<{name}> is any text up to the next slash. One segment can hold several
  30. variables and literal text, for example C<{map}.xml>.
  31. =item C<{name+}> is the rest of the path, slashes and all. It must be the whole
  32. last segment, as in C</lib/{module+}>. Declare its parameter with the name
  33. C<name+>. The query gives its value to the callback as C<name>.
  34. =back
  35. A literal character matches only itself, so the C<.> of C</humans.txt> matches
  36. only a dot. No variable matches a newline.
  37. When more than one template matches a path, the most concrete one wins. The
  38. segments are compared in order. A literal segment comes first, then a segment
  39. of literal text and variables, then a lone C<{name}>, then a C<{name+}>. If
  40. that does not decide, the template with more literal characters wins, and then
  41. the template that sorts first as a string. So the order never depends on the
  42. order of a hash.
  43. =cut
  44. my @methods = qw{get put post delete options head patch trace};
  45. # The keys of the query that tPSGI sets itself. The query string can hold
  46. # them without a 400, because route() replaces them anyway.
  47. my @reserved = qw{tpsgi ip ua user referer route dispatcher};
  48. # How concrete a segment is. A lower rank wins.
  49. my %rank = (
  50. literal => 0,
  51. mixed => 1,
  52. single => 2,
  53. rest => 3,
  54. );
  55. =head1 METHODS
  56. =head2 new(@pairs) = TPSGI::Router
  57. Compile the documents. @pairs is a list of names and documents, for example
  58. the file of each router and its C<%openapi>. The name goes into each error.
  59. new() dies with the reason when a document is not valid OpenAPI 3, when an
  60. operation has no C<x-tpsgi-callback>, or when its callback is a name that is
  61. not a defined sub. It also dies on two templates that differ only in the names
  62. of their variables, and on one template and method in two documents.
  63. =cut
  64. sub new {
  65. my ( $class, @pairs ) = @_;
  66. my $self = bless( { by_template => {}, shapes => {}, exact => {} }, $class );
  67. while ( my ( $name, $document ) = splice( @pairs, 0, 2 ) ) {
  68. $self->add_document( $name, $document );
  69. }
  70. my @templated = grep { !$_->{literal} } values( %{ $self->{by_template} } );
  71. $self->{templated} = [ sort { by_precedence( $a, $b ) } @templated ];
  72. $self->{exact}{ $_->{template} } = $_ for grep { $_->{literal} } values( %{ $self->{by_template} } );
  73. return $self;
  74. }
  75. =head2 json_booleans($data) = copy
  76. A copy of $data in which each of perl's own booleans, such as the C<true> that
  77. C<use 5.040> gives a router, is a Cpanel::JSON::XS boolean. JSON::Validator
  78. 5.19 compares C<enum> values by a checksum, so a native true does not equal
  79. the true of the OpenAPI schema, and C<required =E<gt> true> fails. Hashes and
  80. arrays are copied. Every other reference is kept, so a callback stays the
  81. same sub.
  82. =cut
  83. sub json_booleans {
  84. my ($data) = @_;
  85. no warnings qw{experimental::builtin};
  86. # Each job is the place a copy goes, and what to copy there.
  87. my $copy;
  88. my @todo = ( [ \$copy, $data ] );
  89. while ( my $job = shift @todo ) {
  90. my ( $slot, $value ) = @$job;
  91. if ( ref $value eq 'HASH' ) {
  92. $$slot = {};
  93. push( @todo, map { [ \$$slot->{$_}, $value->{$_} ] } keys(%$value) );
  94. }
  95. elsif ( ref $value eq 'ARRAY' ) {
  96. $$slot = [];
  97. push( @todo, map { [ \$$slot->[$_], $value->[$_] ] } 0 .. $#$value );
  98. }
  99. elsif ( !ref $value && builtin::is_bool($value) ) {
  100. $$slot = $value ? Cpanel::JSON::XS::true : Cpanel::JSON::XS::false;
  101. }
  102. else {
  103. $$slot = $value;
  104. }
  105. }
  106. return $copy;
  107. }
  108. =head2 add_document($name, $document)
  109. Check $document as OpenAPI 3, and add each of its operations. new() calls it
  110. for each pair.
  111. =cut
  112. sub add_document {
  113. my ( $self, $name, $document ) = @_;
  114. die "$name: the router's %openapi is not a HASH reference\n" unless ref $document eq 'HASH';
  115. my $schema = JSON::Validator::Schema::OpenAPIv3->new( json_booleans($document) );
  116. my @errors = @{ $schema->errors };
  117. die "$name: %openapi is not a valid OpenAPI 3 document:\n" . join( "\n", map { " $_" } @errors ) . "\n" if @errors;
  118. my $paths = $schema->get( ['paths'] ) // {};
  119. foreach my $template ( sort keys(%$paths) ) {
  120. my $matcher = $self->matcher_for( $name, $template );
  121. foreach my $method ( grep { exists $paths->{$template}{$_} } @methods ) {
  122. die "$name: \U$method\E $template is also a route of $matcher->{methods}{$method}{router}\n" if $matcher->{methods}{$method};
  123. my $operation = $paths->{$template}{$method};
  124. $matcher->{methods}{$method} = {
  125. router => $name,
  126. template => $template,
  127. method => $method,
  128. operation => $operation,
  129. schema => $schema,
  130. callback => resolve_callback( "$name: \U$method\E $template", $operation->{'x-tpsgi-callback'} ),
  131. %{ $self->rules_for( $schema, $method, $template ) },
  132. };
  133. }
  134. }
  135. return;
  136. }
  137. =head2 matcher_for($name, $template) = HASHREF
  138. The compiled form of $template, made the first time it is asked for. It dies
  139. when $template is not a template, and when another template differs from it
  140. only in the names of its variables.
  141. =cut
  142. sub matcher_for {
  143. my ( $self, $name, $template ) = @_;
  144. return $self->{by_template}{$template} if $self->{by_template}{$template};
  145. my $matcher = compile_template($template);
  146. my $twin = $self->{shapes}{ $matcher->{shape} };
  147. die "$name: $template and $twin match the same paths, and differ only in the names of their variables\n" if $twin;
  148. $self->{shapes}{ $matcher->{shape} } = $template;
  149. return $self->{by_template}{$template} = $matcher;
  150. }
  151. =head2 compile_template($template) = HASHREF
  152. A function. The compiled form of $template: its regular expression, the names of
  153. its variables, the rank of each segment, its count of literal characters, and
  154. its shape, which is the template with every variable name left out. It dies
  155. when the template does not start with a slash, when a brace is not part of a
  156. variable, and when a C<{name+}> is not the whole last segment.
  157. =cut
  158. sub compile_template {
  159. my ($template) = @_;
  160. die "The template $template does not start with a slash\n" unless index( $template, '/' ) == 0;
  161. my @segments = split( m{/}, substr( $template, 1 ), -1 );
  162. my ( @regex, @ranks, @variables );
  163. my $literal_length = 0;
  164. my $shape = '';
  165. foreach my $i ( 0 .. $#segments ) {
  166. my $segment = $segments[$i];
  167. my @parts = $segment =~ m/ ( \{ [^{}]* \} | [^{}]+ ) /g;
  168. die "The template $template has a brace that is not part of a {variable}\n" unless join( '', @parts ) eq $segment;
  169. my ( $regex, $variables ) = ( '', 0 );
  170. foreach my $part (@parts) {
  171. my ($variable) = $part =~ m/ \A \{ ( [^{}]+ ) \} \z /;
  172. if ( !defined $variable ) {
  173. $regex .= quotemeta($part);
  174. $shape .= $part;
  175. $literal_length += length($part);
  176. next;
  177. }
  178. $variables++;
  179. my $is_rest = $variable =~ s/ [+] \z //;
  180. die "The template $template has an empty {variable}\n" if $variable eq '';
  181. die "In the template $template, {$variable+} is not the whole last segment\n" if $is_rest && ( @parts > 1 || $i < $#segments );
  182. # \N, so that no variable takes a newline that a %0A decoded to.
  183. $regex .= $is_rest ? '(\N+)' : '([^/\n]+?)';
  184. $shape .= $is_rest ? '{+}' : '{}';
  185. push( @variables, $variable );
  186. }
  187. $shape .= '/' if $i < $#segments;
  188. my $kind = !$variables ? 'literal' : @parts > 1 ? 'mixed' : $regex eq '(\N+)' ? 'rest' : 'single';
  189. push( @ranks, $rank{$kind} );
  190. push( @regex, $regex );
  191. }
  192. my $source = '\A/' . join( '/', @regex ) . '\z';
  193. return {
  194. template => $template,
  195. regex => qr/$source/,
  196. variables => \@variables,
  197. ranks => \@ranks,
  198. literal_length => $literal_length,
  199. literal => !@variables,
  200. shape => "/$shape",
  201. methods => {},
  202. };
  203. }
  204. =head2 by_precedence($a, $b) = INTEGER
  205. A function, for sort. Below zero when the compiled template $a is tried
  206. before $b, as DESCRIPTION says.
  207. =cut
  208. sub by_precedence {
  209. my ( $left, $right ) = @_;
  210. my @l = @{ $left->{ranks} };
  211. my @r = @{ $right->{ranks} };
  212. foreach my $i ( 0 .. List::Util::min( $#l, $#r ) ) {
  213. return $l[$i] <=> $r[$i] if $l[$i] != $r[$i];
  214. }
  215. return @l <=> @r || $right->{literal_length} <=> $left->{literal_length} || $left->{template} cmp $right->{template};
  216. }
  217. =head2 resolve_callback($what, $callback) = CODE
  218. A function. $callback when it is a CODE reference, and the sub that it names
  219. when it is a fully qualified name. It dies, and names $what, when there is no
  220. callback or no such sub.
  221. =cut
  222. sub resolve_callback {
  223. my ( $what, $callback ) = @_;
  224. die "$what has no x-tpsgi-callback\n" unless defined $callback;
  225. return $callback if ref $callback eq 'CODE';
  226. die "$what: x-tpsgi-callback is neither a CODE reference nor the name of a sub\n" if ref $callback;
  227. my $code = do {
  228. no strict 'refs'; # a sub that the router names
  229. defined &{$callback} ? \&{$callback} : undef;
  230. };
  231. die "$what: x-tpsgi-callback names $callback, which is not a defined sub\n" unless $code;
  232. return $code;
  233. }
  234. =head2 rules_for($schema, $method, $template) = HASHREF
  235. What validate() checks of one operation beyond its schema: the names of its
  236. query parameters, whether an undeclared key in the query is a 400, and the
  237. media types of its request body.
  238. =cut
  239. sub rules_for {
  240. my ( $self, $schema, $method, $template ) = @_;
  241. my @parameters = @{ $schema->parameters_for_request( [ $method, $template ] ) };
  242. my %declared = map { $_->{name} => 1 } grep { $_->{in} eq 'query' } @parameters;
  243. my ($body) = grep { $_->{in} eq 'body' } @parameters;
  244. return {
  245. query_names => \%declared,
  246. strict => ( %declared || $body ) ? 1 : 0,
  247. body_types => $body ? [ sort keys( %{ $body->{content} // {} } ) ] : undef,
  248. };
  249. }
  250. =head2 match($method, $path) = HASHREF
  251. The route of a request, or undef when no template matches $path.
  252. The route holds C<template>, C<method>, C<operation>, C<callback>, C<schema>
  253. and C<path>, which maps each variable to its value. The names in C<path> have
  254. no C<+>.
  255. When a template matches and none of the matching templates has an operation
  256. for $method, the HASH holds C<status>, which is 405, and C<allow>, the methods
  257. that they do have. A HEAD request falls back to C<get>.
  258. =cut
  259. sub match {
  260. my ( $self, $method, $path ) = @_;
  261. $method = lc($method);
  262. my @tried;
  263. foreach my $matcher ( grep { defined } $self->{exact}{$path}, @{ $self->{templated} } ) {
  264. my @values = $matcher->{literal} ? () : $path =~ $matcher->{regex};
  265. next unless $matcher->{literal} || @values;
  266. push( @tried, $matcher );
  267. my $route = $matcher->{methods}{$method};
  268. $route //= $matcher->{methods}{get} if $method eq 'head';
  269. next unless $route;
  270. my %values;
  271. @values{ @{ $matcher->{variables} } } = @values;
  272. return { %$route, path => \%values };
  273. }
  274. return unless @tried;
  275. my %allow = map {
  276. map { uc($_) => 1 }
  277. keys( %{ $_->{methods} } )
  278. } @tried;
  279. $allow{HEAD} = 1 if $allow{GET};
  280. return { status => 405, allow => [ sort keys(%allow) ] };
  281. }
  282. =head2 validate($route, %request) = LIST
  283. Check a request against the operation of the $route that match() returned.
  284. %request holds C<query> and C<body>, each a HASH reference, C<content_type>,
  285. and C<env>, the PSGI environment, from which the headers and cookies come.
  286. It returns an empty list when the request is valid. Otherwise it returns a
  287. status and the reasons. The status is 415 when the operation has a request
  288. body and the media type of the request is not among its C<content>. It is 400
  289. when a parameter or the body does not match its schema, and when the query
  290. holds a key that the operation does not declare. The last rule holds only for
  291. an operation that declares a query parameter or a request body.
  292. The values of C<path>, C<query> and C<body> are coerced in place, so a parameter
  293. of type integer arrives as a number.
  294. =cut
  295. sub validate {
  296. my ( $self, $route, %request ) = @_;
  297. my $query = $request{query} // {};
  298. my $body = $request{body};
  299. my $env = $request{env} // {};
  300. if ( $route->{body_types} && defined $body ) {
  301. my $type = $request{content_type} // '';
  302. return ( 415, "/body: '$type' is not one of @{ $route->{body_types} }" ) unless JSON::Validator::Util::negotiate_content_type( $route->{body_types}, $type );
  303. }
  304. if ( $route->{strict} ) {
  305. my %known = ( %{ $route->{query_names} }, map { $_ => 1 } @reserved );
  306. my @undeclared = grep { !$known{$_} } sort keys(%$query);
  307. return ( 400, map { "/$_: the operation does not declare this parameter" } @undeclared ) if @undeclared;
  308. }
  309. my %cookies = map { split( m/=/, $_, 2 ) } split( m/ ; [ ]* /, $env->{HTTP_COOKIE} // '' );
  310. my %headers = map {
  311. my $name = lc( substr( $_, 5 ) );
  312. $name =~ tr/_/-/;
  313. ( $name => $env->{$_} )
  314. } grep { index( $_, 'HTTP_' ) == 0 } keys(%$env);
  315. my @seen;
  316. my $getter = sub {
  317. my ( $values, $fold ) = @_;
  318. return sub {
  319. my ($name) = @_;
  320. my $key = $fold->($name);
  321. my $value = { exists => exists $values->{$key}, value => $values->{$key} };
  322. push( @seen, [ $values, $key, $value ] );
  323. return $value;
  324. };
  325. };
  326. my $as_is = sub { return $_[0] };
  327. my @errors = $route->{schema}->validate_request(
  328. [ $route->{method}, $route->{template} ],
  329. {
  330. path => $getter->( $route->{path}, sub { my $name = shift; $name =~ s/ [+] \z //; return $name } ),
  331. query => $getter->( $query, $as_is ),
  332. header => $getter->( \%headers, sub { return lc( $_[0] ) } ),
  333. cookie => $getter->( \%cookies, $as_is ),
  334. body => sub {
  335. return { exists => defined $body, value => $body, content_type => $request{content_type} };
  336. },
  337. }
  338. );
  339. # validate_request() coerces its copy of each value, not the caller's.
  340. foreach my $seen (@seen) {
  341. my ( $values, $key, $value ) = @$seen;
  342. $values->{$key} = $value->{value} if $value->{exists};
  343. }
  344. return unless @errors;
  345. return ( 400, map { "$_" } @errors );
  346. }
  347. =head2 routes() = LIST
  348. Each route as a HASH reference with C<template> and C<method>, in the order
  349. match() tries them, for a caller that lists them.
  350. =cut
  351. sub routes {
  352. my ($self) = @_;
  353. my @matchers = ( map { $self->{exact}{$_} } sort keys( %{ $self->{exact} } ) );
  354. push( @matchers, @{ $self->{templated} } );
  355. return map {
  356. my $matcher = $_;
  357. map { $matcher->{methods}{$_} } grep { $matcher->{methods}{$_} } @methods
  358. } @matchers;
  359. }
  360. 1;