Router.pm 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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. # How concrete a segment is. A lower rank wins.
  46. my %rank = (
  47. literal => 0,
  48. mixed => 1,
  49. single => 2,
  50. rest => 3,
  51. );
  52. =head1 METHODS
  53. =head2 new(@pairs) = TPSGI::Router
  54. Compile the documents. @pairs is a list of names and documents, for example
  55. the file of each router and its C<%openapi>. The name goes into each error.
  56. new() dies with the reason when a document is not valid OpenAPI 3, when an
  57. operation has no C<x-tpsgi-callback>, or when its callback is a name that is
  58. not a defined sub. It also dies on two templates that differ only in the names
  59. of their variables, and on one template and method in two documents.
  60. The path, the query and the body of a request share one input, so new() also
  61. dies on an operation that gives one name to two of them, or whose
  62. C<x-tpsgi-data> sets a name that the request can give.
  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. my $rules = $self->rules_for( $schema, $method, $template );
  125. check_names( "$name: \U$method\E $template", $operation, $rules );
  126. $matcher->{methods}{$method} = {
  127. router => $name,
  128. template => $template,
  129. method => $method,
  130. operation => $operation,
  131. schema => $schema,
  132. callback => resolve_callback( "$name: \U$method\E $template", $operation->{'x-tpsgi-callback'} ),
  133. %$rules,
  134. };
  135. }
  136. }
  137. return;
  138. }
  139. =head2 matcher_for($name, $template) = HASHREF
  140. The compiled form of $template, made the first time it is asked for. It dies
  141. when $template is not a template, and when another template differs from it
  142. only in the names of its variables.
  143. =cut
  144. sub matcher_for {
  145. my ( $self, $name, $template ) = @_;
  146. return $self->{by_template}{$template} if $self->{by_template}{$template};
  147. my $matcher = compile_template($template);
  148. my $twin = $self->{shapes}{ $matcher->{shape} };
  149. die "$name: $template and $twin match the same paths, and differ only in the names of their variables\n" if $twin;
  150. $self->{shapes}{ $matcher->{shape} } = $template;
  151. return $self->{by_template}{$template} = $matcher;
  152. }
  153. =head2 compile_template($template) = HASHREF
  154. A function. The compiled form of $template: its regular expression, the names of
  155. its variables, the rank of each segment, its count of literal characters, and
  156. its shape, which is the template with every variable name left out. It dies
  157. when the template does not start with a slash, when a brace is not part of a
  158. variable, and when a C<{name+}> is not the whole last segment.
  159. =cut
  160. sub compile_template {
  161. my ($template) = @_;
  162. die "The template $template does not start with a slash\n" unless index( $template, '/' ) == 0;
  163. my @segments = split( m{/}, substr( $template, 1 ), -1 );
  164. my ( @regex, @ranks, @variables );
  165. my $literal_length = 0;
  166. my $shape = '';
  167. foreach my $i ( 0 .. $#segments ) {
  168. my $segment = $segments[$i];
  169. my @parts = $segment =~ m/ ( \{ [^{}]* \} | [^{}]+ ) /g;
  170. die "The template $template has a brace that is not part of a {variable}\n" unless join( '', @parts ) eq $segment;
  171. my ( $regex, $variables ) = ( '', 0 );
  172. foreach my $part (@parts) {
  173. my ($variable) = $part =~ m/ \A \{ ( [^{}]+ ) \} \z /;
  174. if ( !defined $variable ) {
  175. $regex .= quotemeta($part);
  176. $shape .= $part;
  177. $literal_length += length($part);
  178. next;
  179. }
  180. $variables++;
  181. my $is_rest = $variable =~ s/ [+] \z //;
  182. die "The template $template has an empty {variable}\n" if $variable eq '';
  183. die "In the template $template, {$variable+} is not the whole last segment\n" if $is_rest && ( @parts > 1 || $i < $#segments );
  184. # \N, so that no variable takes a newline that a %0A decoded to.
  185. $regex .= $is_rest ? '(\N+)' : '([^/\n]+?)';
  186. $shape .= $is_rest ? '{+}' : '{}';
  187. push( @variables, $variable );
  188. }
  189. $shape .= '/' if $i < $#segments;
  190. my $kind = !$variables ? 'literal' : @parts > 1 ? 'mixed' : $regex eq '(\N+)' ? 'rest' : 'single';
  191. push( @ranks, $rank{$kind} );
  192. push( @regex, $regex );
  193. }
  194. my $source = '\A/' . join( '/', @regex ) . '\z';
  195. return {
  196. template => $template,
  197. regex => qr/$source/,
  198. variables => \@variables,
  199. ranks => \@ranks,
  200. literal_length => $literal_length,
  201. literal => !@variables,
  202. shape => "/$shape",
  203. methods => {},
  204. };
  205. }
  206. =head2 by_precedence($a, $b) = INTEGER
  207. A function, for sort. Below zero when the compiled template $a is tried
  208. before $b, as DESCRIPTION says.
  209. =cut
  210. sub by_precedence {
  211. my ( $left, $right ) = @_;
  212. my @l = @{ $left->{ranks} };
  213. my @r = @{ $right->{ranks} };
  214. foreach my $i ( 0 .. List::Util::min( $#l, $#r ) ) {
  215. return $l[$i] <=> $r[$i] if $l[$i] != $r[$i];
  216. }
  217. return @l <=> @r || $right->{literal_length} <=> $left->{literal_length} || $left->{template} cmp $right->{template};
  218. }
  219. =head2 resolve_callback($what, $callback) = CODE
  220. A function. $callback when it is a CODE reference, and the sub that it names
  221. when it is a fully qualified name. It dies, and names $what, when there is no
  222. callback or no such sub.
  223. =cut
  224. sub resolve_callback {
  225. my ( $what, $callback ) = @_;
  226. die "$what has no x-tpsgi-callback\n" unless defined $callback;
  227. return $callback if ref $callback eq 'CODE';
  228. die "$what: x-tpsgi-callback is neither a CODE reference nor the name of a sub\n" if ref $callback;
  229. my $code = do {
  230. no strict 'refs'; # a sub that the router names
  231. defined &{$callback} ? \&{$callback} : undef;
  232. };
  233. die "$what: x-tpsgi-callback names $callback, which is not a defined sub\n" unless $code;
  234. return $code;
  235. }
  236. =head2 rules_for($schema, $method, $template) = HASHREF
  237. What validate() checks of one operation beyond its schema: the names of its
  238. path variables and its query parameters, the media types of its request body,
  239. and for each media type the names of the properties that its schema names.
  240. C<closed> holds the media types whose schema does not say
  241. C<additionalProperties>, which take no other property.
  242. =cut
  243. sub rules_for {
  244. my ( $self, $schema, $method, $template ) = @_;
  245. my @parameters = @{ $schema->parameters_for_request( [ $method, $template ] ) };
  246. my %in = map {
  247. my $in = $_;
  248. ( $in => { map { ( $_->{name} =~ s/ [+] \z //r ) => 1 } grep { $_->{in} eq $in } @parameters } )
  249. } qw{path query};
  250. my ($body) = grep { $_->{in} eq 'body' } @parameters;
  251. my @types = $body ? sort keys( %{ $body->{content} // {} } ) : ();
  252. # get() rather than the content itself, because get() follows a $ref.
  253. my ( %body_names, %closed );
  254. foreach my $type (@types) {
  255. my $of = $schema->get( [ 'paths', $template, $method, 'requestBody', 'content', $type, 'schema' ] ) // {};
  256. $body_names{$type} = { map { $_ => 1 } keys( %{ $of->{properties} // {} } ) };
  257. $closed{$type} = 1 unless exists $of->{additionalProperties};
  258. }
  259. return {
  260. path_names => $in{path},
  261. query_names => $in{query},
  262. body_types => $body ? \@types : undef,
  263. body_names => \%body_names,
  264. closed => \%closed,
  265. };
  266. }
  267. =head2 check_names($what, $operation, $rules)
  268. A function. Die, and name $what, when the rules_for() of $operation give one
  269. name to two of the path, the query and the body, or when its C<x-tpsgi-data>
  270. sets a name that one of them declares.
  271. =cut
  272. sub check_names {
  273. my ( $what, $operation, $rules ) = @_;
  274. my %where;
  275. push( @{ $where{$_} }, 'the path' ) for keys( %{ $rules->{path_names} } );
  276. push( @{ $where{$_} }, 'the query' ) for keys( %{ $rules->{query_names} } );
  277. push( @{ $where{$_} }, 'the body' ) for List::Util::uniq( map { keys(%$_) } values( %{ $rules->{body_names} } ) );
  278. my @twice = grep { @{ $where{$_} } > 1 } sort keys(%where);
  279. die "$what: $_ is a name of " . join( ' and of ', @{ $where{$_} } ) . "\n" for @twice;
  280. my $data = $operation->{'x-tpsgi-data'};
  281. my @fixed = ref $data eq 'HASH' ? grep { $where{$_} } sort keys(%$data) : ();
  282. die "$what: x-tpsgi-data sets $_, which is a name of $where{$_}[0]\n" for @fixed;
  283. return;
  284. }
  285. =head2 match($method, $path) = HASHREF
  286. The route of a request, or undef when no template matches $path.
  287. The route holds C<template>, C<method>, C<operation>, C<callback>, C<schema>
  288. and C<path>, which maps each variable to its value. The names in C<path> have
  289. no C<+>.
  290. When a template matches and none of the matching templates has an operation
  291. for $method, the HASH holds C<status>, which is 405, and C<allow>, the methods
  292. that they do have. A HEAD request falls back to C<get>.
  293. =cut
  294. sub match {
  295. my ( $self, $method, $path ) = @_;
  296. $method = lc($method);
  297. my @tried;
  298. foreach my $matcher ( grep { defined } $self->{exact}{$path}, @{ $self->{templated} } ) {
  299. my @values = $matcher->{literal} ? () : $path =~ $matcher->{regex};
  300. next unless $matcher->{literal} || @values;
  301. push( @tried, $matcher );
  302. my $route = $matcher->{methods}{$method};
  303. $route //= $matcher->{methods}{get} if $method eq 'head';
  304. next unless $route;
  305. my %values;
  306. @values{ @{ $matcher->{variables} } } = @values;
  307. return { %$route, path => \%values };
  308. }
  309. return unless @tried;
  310. my %allow = map {
  311. map { uc($_) => 1 }
  312. keys( %{ $_->{methods} } )
  313. } @tried;
  314. $allow{HEAD} = 1 if $allow{GET};
  315. return { status => 405, allow => [ sort keys(%allow) ] };
  316. }
  317. =head2 validate($route, %request) = LIST
  318. Check a request against the operation of the $route that match() returned.
  319. %request holds C<query> and C<body>, each a HASH reference, C<uploads>, the
  320. names of the files in the body, C<content_type>, and C<env>, the PSGI
  321. environment, from which the headers and cookies come.
  322. It returns an empty list when the request is valid. Otherwise it returns a
  323. status and the reasons. The status is 415 when the operation has a request
  324. body and the media type of the request is not among its C<content>. It is 400
  325. when a parameter or the body does not match its schema, and when the request
  326. holds a name that the operation does not declare:
  327. =over
  328. =item a key of the query that is not a query parameter;
  329. =item any field or file, when the operation has no C<requestBody>;
  330. =item a field or file that is not a property of the schema for its media type,
  331. unless that schema says C<additionalProperties>;
  332. =item a field or file that a schema with C<additionalProperties> takes, whose
  333. name is a path variable or a query parameter.
  334. =back
  335. An operation in CGI mode is never validated, because route() builds no query
  336. for it.
  337. The values of C<path>, C<query> and C<body> are coerced in place, so a parameter
  338. of type integer arrives as a number.
  339. =cut
  340. sub validate {
  341. my ( $self, $route, %request ) = @_;
  342. my $query = $request{query} // {};
  343. my $body = $request{body};
  344. my $env = $request{env} // {};
  345. my @sent = sort( keys( %{ $body // {} } ), @{ $request{uploads} // [] } );
  346. if ( $route->{body_types} && ( defined $body || @sent ) ) {
  347. my $type = $request{content_type} // '';
  348. my $actual = JSON::Validator::Util::negotiate_content_type( $route->{body_types}, $type );
  349. return ( 415, "/body: '$type' is not one of @{ $route->{body_types} }" ) unless $actual;
  350. my $names = $route->{body_names}{$actual};
  351. my @extra = $route->{closed}{$actual} ? grep { !$names->{$_} } @sent : ();
  352. return ( 400, map { "/body/$_: the schema of $actual does not declare this property" } @extra ) if @extra;
  353. my @taken = grep { $route->{path_names}{$_} || $route->{query_names}{$_} } @sent;
  354. return ( 400, map { "/body/$_: the path or the query declares this name" } @taken ) if @taken;
  355. }
  356. return ( 400, map { "/body/$_: the operation takes no body" } @sent ) if @sent && !$route->{body_types};
  357. my @undeclared = grep { !$route->{query_names}{$_} } sort keys(%$query);
  358. return ( 400, map { "/$_: the operation does not declare this parameter" } @undeclared ) if @undeclared;
  359. my %cookies = map { split( m/=/, $_, 2 ) } split( m/ ; [ ]* /, $env->{HTTP_COOKIE} // '' );
  360. my %headers = map {
  361. my $name = lc( substr( $_, 5 ) );
  362. $name =~ tr/_/-/;
  363. ( $name => $env->{$_} )
  364. } grep { index( $_, 'HTTP_' ) == 0 } keys(%$env);
  365. my @seen;
  366. my $getter = sub {
  367. my ( $values, $fold ) = @_;
  368. return sub {
  369. my ($name) = @_;
  370. my $key = $fold->($name);
  371. my $value = { exists => exists $values->{$key}, value => $values->{$key} };
  372. push( @seen, [ $values, $key, $value ] );
  373. return $value;
  374. };
  375. };
  376. my $as_is = sub { return $_[0] };
  377. my @errors = $route->{schema}->validate_request(
  378. [ $route->{method}, $route->{template} ],
  379. {
  380. path => $getter->( $route->{path}, sub { my $name = shift; $name =~ s/ [+] \z //; return $name } ),
  381. query => $getter->( $query, $as_is ),
  382. header => $getter->( \%headers, sub { return lc( $_[0] ) } ),
  383. cookie => $getter->( \%cookies, $as_is ),
  384. body => sub {
  385. return { exists => defined $body, value => $body, content_type => $request{content_type} };
  386. },
  387. }
  388. );
  389. # validate_request() coerces its copy of each value, not the caller's.
  390. foreach my $seen (@seen) {
  391. my ( $values, $key, $value ) = @$seen;
  392. $values->{$key} = $value->{value} if $value->{exists};
  393. }
  394. return unless @errors;
  395. return ( 400, map { "$_" } @errors );
  396. }
  397. =head2 routes() = LIST
  398. Each route as a HASH reference with C<template> and C<method>, in the order
  399. match() tries them, for a caller that lists them.
  400. =cut
  401. sub routes {
  402. my ($self) = @_;
  403. my @matchers = ( map { $self->{exact}{$_} } sort keys( %{ $self->{exact} } ) );
  404. push( @matchers, @{ $self->{templated} } );
  405. return map {
  406. my $matcher = $_;
  407. map { $matcher->{methods}{$_} } grep { $matcher->{methods}{$_} } @methods
  408. } @matchers;
  409. }
  410. 1;