Router.pm 15 KB

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