Router.pm 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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, the media types of its request body, and for each media type
  237. the names of the properties that its schema takes. A schema that says
  238. C<additionalProperties> decides for itself, so it has no names here.
  239. =cut
  240. sub rules_for {
  241. my ( $self, $schema, $method, $template ) = @_;
  242. my @parameters = @{ $schema->parameters_for_request( [ $method, $template ] ) };
  243. my %declared = map { $_->{name} => 1 } grep { $_->{in} eq 'query' } @parameters;
  244. my ($body) = grep { $_->{in} eq 'body' } @parameters;
  245. my @types = $body ? sort keys( %{ $body->{content} // {} } ) : ();
  246. # get() rather than the content itself, because get() follows a $ref.
  247. my %body_names = map {
  248. my $of = $schema->get( [ 'paths', $template, $method, 'requestBody', 'content', $_, 'schema' ] ) // {};
  249. exists $of->{additionalProperties} ? () : ( $_ => { map { $_ => 1 } keys( %{ $of->{properties} // {} } ) } );
  250. } @types;
  251. return {
  252. query_names => \%declared,
  253. body_types => $body ? \@types : undef,
  254. body_names => \%body_names,
  255. };
  256. }
  257. =head2 match($method, $path) = HASHREF
  258. The route of a request, or undef when no template matches $path.
  259. The route holds C<template>, C<method>, C<operation>, C<callback>, C<schema>
  260. and C<path>, which maps each variable to its value. The names in C<path> have
  261. no C<+>.
  262. When a template matches and none of the matching templates has an operation
  263. for $method, the HASH holds C<status>, which is 405, and C<allow>, the methods
  264. that they do have. A HEAD request falls back to C<get>.
  265. =cut
  266. sub match {
  267. my ( $self, $method, $path ) = @_;
  268. $method = lc($method);
  269. my @tried;
  270. foreach my $matcher ( grep { defined } $self->{exact}{$path}, @{ $self->{templated} } ) {
  271. my @values = $matcher->{literal} ? () : $path =~ $matcher->{regex};
  272. next unless $matcher->{literal} || @values;
  273. push( @tried, $matcher );
  274. my $route = $matcher->{methods}{$method};
  275. $route //= $matcher->{methods}{get} if $method eq 'head';
  276. next unless $route;
  277. my %values;
  278. @values{ @{ $matcher->{variables} } } = @values;
  279. return { %$route, path => \%values };
  280. }
  281. return unless @tried;
  282. my %allow = map {
  283. map { uc($_) => 1 }
  284. keys( %{ $_->{methods} } )
  285. } @tried;
  286. $allow{HEAD} = 1 if $allow{GET};
  287. return { status => 405, allow => [ sort keys(%allow) ] };
  288. }
  289. =head2 validate($route, %request) = LIST
  290. Check a request against the operation of the $route that match() returned.
  291. %request holds C<query> and C<body>, each a HASH reference, C<uploads>, the
  292. names of the files in the body, C<content_type>, and C<env>, the PSGI
  293. environment, from which the headers and cookies come.
  294. It returns an empty list when the request is valid. Otherwise it returns a
  295. status and the reasons. The status is 415 when the operation has a request
  296. body and the media type of the request is not among its C<content>. It is 400
  297. when a parameter or the body does not match its schema, and when the request
  298. holds a name that the operation does not declare:
  299. =over
  300. =item a key of the query that is not a query parameter, unless tPSGI sets
  301. that key itself;
  302. =item any field or file, when the operation has no C<requestBody>;
  303. =item a field or file that is not a property of the schema for its media type,
  304. unless that schema says C<additionalProperties>.
  305. =back
  306. An operation in CGI mode is never validated, because route() builds no query
  307. for it.
  308. The values of C<path>, C<query> and C<body> are coerced in place, so a parameter
  309. of type integer arrives as a number.
  310. =cut
  311. sub validate {
  312. my ( $self, $route, %request ) = @_;
  313. my $query = $request{query} // {};
  314. my $body = $request{body};
  315. my $env = $request{env} // {};
  316. my @sent = sort( keys( %{ $body // {} } ), @{ $request{uploads} // [] } );
  317. if ( $route->{body_types} && ( defined $body || @sent ) ) {
  318. my $type = $request{content_type} // '';
  319. my $actual = JSON::Validator::Util::negotiate_content_type( $route->{body_types}, $type );
  320. return ( 415, "/body: '$type' is not one of @{ $route->{body_types} }" ) unless $actual;
  321. my $names = $route->{body_names}{$actual};
  322. my @extra = $names ? grep { !$names->{$_} } @sent : ();
  323. return ( 400, map { "/body/$_: the schema of $actual does not declare this property" } @extra ) if @extra;
  324. }
  325. return ( 400, map { "/body/$_: the operation takes no body" } @sent ) if @sent && !$route->{body_types};
  326. my %known = ( %{ $route->{query_names} }, map { $_ => 1 } @reserved );
  327. my @undeclared = grep { !$known{$_} } sort keys(%$query);
  328. return ( 400, map { "/$_: the operation does not declare this parameter" } @undeclared ) if @undeclared;
  329. my %cookies = map { split( m/=/, $_, 2 ) } split( m/ ; [ ]* /, $env->{HTTP_COOKIE} // '' );
  330. my %headers = map {
  331. my $name = lc( substr( $_, 5 ) );
  332. $name =~ tr/_/-/;
  333. ( $name => $env->{$_} )
  334. } grep { index( $_, 'HTTP_' ) == 0 } keys(%$env);
  335. my @seen;
  336. my $getter = sub {
  337. my ( $values, $fold ) = @_;
  338. return sub {
  339. my ($name) = @_;
  340. my $key = $fold->($name);
  341. my $value = { exists => exists $values->{$key}, value => $values->{$key} };
  342. push( @seen, [ $values, $key, $value ] );
  343. return $value;
  344. };
  345. };
  346. my $as_is = sub { return $_[0] };
  347. my @errors = $route->{schema}->validate_request(
  348. [ $route->{method}, $route->{template} ],
  349. {
  350. path => $getter->( $route->{path}, sub { my $name = shift; $name =~ s/ [+] \z //; return $name } ),
  351. query => $getter->( $query, $as_is ),
  352. header => $getter->( \%headers, sub { return lc( $_[0] ) } ),
  353. cookie => $getter->( \%cookies, $as_is ),
  354. body => sub {
  355. return { exists => defined $body, value => $body, content_type => $request{content_type} };
  356. },
  357. }
  358. );
  359. # validate_request() coerces its copy of each value, not the caller's.
  360. foreach my $seen (@seen) {
  361. my ( $values, $key, $value ) = @$seen;
  362. $values->{$key} = $value->{value} if $value->{exists};
  363. }
  364. return unless @errors;
  365. return ( 400, map { "$_" } @errors );
  366. }
  367. =head2 routes() = LIST
  368. Each route as a HASH reference with C<template> and C<method>, in the order
  369. match() tries them, for a caller that lists them.
  370. =cut
  371. sub routes {
  372. my ($self) = @_;
  373. my @matchers = ( map { $self->{exact}{$_} } sort keys( %{ $self->{exact} } ) );
  374. push( @matchers, @{ $self->{templated} } );
  375. return map {
  376. my $matcher = $_;
  377. map { $matcher->{methods}{$_} } grep { $matcher->{methods}{$_} } @methods
  378. } @matchers;
  379. }
  380. 1;