package TPSGI::Router; # ABSTRACT: Match requests to the operations of OpenAPI 3 documents, and validate them use 5.040; use warnings FATAL => 'all'; use re '/aasx'; use List::Util (); use JSON::Validator::Schema::OpenAPIv3; use JSON::Validator::Util (); =head1 SYNOPSIS my $router = TPSGI::Router->new( 'lib/My/Routes.pm' => \%My::Routes::openapi ); sub answer { my ( $tpsgi, $method, $path, %query ) = @_; my $request = { method => $method, fullpath => $path }; my $route = $router->match( $method, $path ); return $tpsgi->notfound($request) unless $route; return $tpsgi->notallowed( $request, @{ $route->{allow} } ) if $route->{status}; my ( $status, @errors ) = $router->validate( $route, query => \%query ); return $tpsgi->badrequest($request) if $status; return $route->{callback}->( $tpsgi, { %query, %{ $route->{path} } } ); } =head1 DESCRIPTION TPSGI::Router compiles the C<%openapi> document of each router once. Then it answers which operation a request is for, and whether the request is valid for that operation. Each key of C is a template. A template is a list of segments, which slashes separate. A segment is made of literal text and variables: =over =item C<{name}> is any text up to the next slash. One segment can hold several variables and literal text, for example C<{map}.xml>. =item C<{name+}> is the rest of the path, slashes and all. It must be the whole last segment, as in C. Declare its parameter with the name C. The query gives its value to the callback as C. =back A literal character matches only itself, so the C<.> of C matches only a dot. No variable matches a newline. When more than one template matches a path, the most concrete one wins. The segments are compared in order. A literal segment comes first, then a segment of literal text and variables, then a lone C<{name}>, then a C<{name+}>. If that does not decide, the template with more literal characters wins, and then the template that sorts first as a string. So the order never depends on the order of a hash. =cut my @methods = qw{get put post delete options head patch trace}; # The keys of the query that tPSGI sets itself. The query string can hold # them without a 400, because route() replaces them anyway. my @reserved = qw{tpsgi ip ua user referer route dispatcher}; # How concrete a segment is. A lower rank wins. my %rank = ( literal => 0, mixed => 1, single => 2, rest => 3, ); =head1 METHODS =head2 new(@pairs) = TPSGI::Router Compile the documents. @pairs is a list of names and documents, for example the file of each router and its C<%openapi>. The name goes into each error. new() dies with the reason when a document is not valid OpenAPI 3, when an operation has no C, or when its callback is a name that is not a defined sub. It also dies on two templates that differ only in the names of their variables, and on one template and method in two documents. =cut sub new { my ( $class, @pairs ) = @_; my $self = bless( { by_template => {}, shapes => {}, exact => {} }, $class ); while ( my ( $name, $document ) = splice( @pairs, 0, 2 ) ) { $self->add_document( $name, $document ); } my @templated = grep { !$_->{literal} } values( %{ $self->{by_template} } ); $self->{templated} = [ sort { by_precedence( $a, $b ) } @templated ]; $self->{exact}{ $_->{template} } = $_ for grep { $_->{literal} } values( %{ $self->{by_template} } ); return $self; } =head2 add_document($name, $document) Check $document as OpenAPI 3, and add each of its operations. new() calls it for each pair. =cut sub add_document { my ( $self, $name, $document ) = @_; die "$name: the router's %openapi is not a HASH reference\n" unless ref $document eq 'HASH'; my $schema = JSON::Validator::Schema::OpenAPIv3->new($document); my @errors = @{ $schema->errors }; die "$name: %openapi is not a valid OpenAPI 3 document:\n" . join( "\n", map { " $_" } @errors ) . "\n" if @errors; my $paths = $schema->get( ['paths'] ) // {}; foreach my $template ( sort keys(%$paths) ) { my $matcher = $self->matcher_for( $name, $template ); foreach my $method ( grep { exists $paths->{$template}{$_} } @methods ) { die "$name: \U$method\E $template is also a route of $matcher->{methods}{$method}{router}\n" if $matcher->{methods}{$method}; my $operation = $paths->{$template}{$method}; $matcher->{methods}{$method} = { router => $name, template => $template, method => $method, operation => $operation, schema => $schema, callback => resolve_callback( "$name: \U$method\E $template", $operation->{'x-tpsgi-callback'} ), %{ $self->rules_for( $schema, $method, $template ) }, }; } } return; } =head2 matcher_for($name, $template) = HASHREF The compiled form of $template, made the first time it is asked for. It dies when $template is not a template, and when another template differs from it only in the names of its variables. =cut sub matcher_for { my ( $self, $name, $template ) = @_; return $self->{by_template}{$template} if $self->{by_template}{$template}; my $matcher = compile_template($template); my $twin = $self->{shapes}{ $matcher->{shape} }; die "$name: $template and $twin match the same paths, and differ only in the names of their variables\n" if $twin; $self->{shapes}{ $matcher->{shape} } = $template; return $self->{by_template}{$template} = $matcher; } =head2 compile_template($template) = HASHREF A function. The compiled form of $template: its regular expression, the names of its variables, the rank of each segment, its count of literal characters, and its shape, which is the template with every variable name left out. It dies when the template does not start with a slash, when a brace is not part of a variable, and when a C<{name+}> is not the whole last segment. =cut sub compile_template { my ($template) = @_; die "The template $template does not start with a slash\n" unless index( $template, '/' ) == 0; my @segments = split( m{/}, substr( $template, 1 ), -1 ); my ( @regex, @ranks, @variables ); my $literal_length = 0; my $shape = ''; foreach my $i ( 0 .. $#segments ) { my $segment = $segments[$i]; my @parts = $segment =~ m/ ( \{ [^{}]* \} | [^{}]+ ) /g; die "The template $template has a brace that is not part of a {variable}\n" unless join( '', @parts ) eq $segment; my ( $regex, $variables ) = ( '', 0 ); foreach my $part (@parts) { my ($variable) = $part =~ m/ \A \{ ( [^{}]+ ) \} \z /; if ( !defined $variable ) { $regex .= quotemeta($part); $shape .= $part; $literal_length += length($part); next; } $variables++; my $is_rest = $variable =~ s/ [+] \z //; die "The template $template has an empty {variable}\n" if $variable eq ''; die "In the template $template, {$variable+} is not the whole last segment\n" if $is_rest && ( @parts > 1 || $i < $#segments ); # \N, so that no variable takes a newline that a %0A decoded to. $regex .= $is_rest ? '(\N+)' : '([^/\n]+?)'; $shape .= $is_rest ? '{+}' : '{}'; push( @variables, $variable ); } $shape .= '/' if $i < $#segments; my $kind = !$variables ? 'literal' : @parts > 1 ? 'mixed' : $regex eq '(\N+)' ? 'rest' : 'single'; push( @ranks, $rank{$kind} ); push( @regex, $regex ); } my $source = '\A/' . join( '/', @regex ) . '\z'; return { template => $template, regex => qr/$source/, variables => \@variables, ranks => \@ranks, literal_length => $literal_length, literal => !@variables, shape => "/$shape", methods => {}, }; } =head2 by_precedence($a, $b) = INTEGER A function, for sort. Below zero when the compiled template $a is tried before $b, as DESCRIPTION says. =cut sub by_precedence { my ( $left, $right ) = @_; my @l = @{ $left->{ranks} }; my @r = @{ $right->{ranks} }; foreach my $i ( 0 .. List::Util::min( $#l, $#r ) ) { return $l[$i] <=> $r[$i] if $l[$i] != $r[$i]; } return @l <=> @r || $right->{literal_length} <=> $left->{literal_length} || $left->{template} cmp $right->{template}; } =head2 resolve_callback($what, $callback) = CODE A function. $callback when it is a CODE reference, and the sub that it names when it is a fully qualified name. It dies, and names $what, when there is no callback or no such sub. =cut sub resolve_callback { my ( $what, $callback ) = @_; die "$what has no x-tpsgi-callback\n" unless defined $callback; return $callback if ref $callback eq 'CODE'; die "$what: x-tpsgi-callback is neither a CODE reference nor the name of a sub\n" if ref $callback; my $code = do { no strict 'refs'; # a sub that the router names defined &{$callback} ? \&{$callback} : undef; }; die "$what: x-tpsgi-callback names $callback, which is not a defined sub\n" unless $code; return $code; } =head2 rules_for($schema, $method, $template) = HASHREF What validate() checks of one operation beyond its schema: the names of its query parameters, whether an undeclared key in the query is a 400, and the media types of its request body. =cut sub rules_for { my ( $self, $schema, $method, $template ) = @_; my @parameters = @{ $schema->parameters_for_request( [ $method, $template ] ) }; my %declared = map { $_->{name} => 1 } grep { $_->{in} eq 'query' } @parameters; my ($body) = grep { $_->{in} eq 'body' } @parameters; return { query_names => \%declared, strict => ( %declared || $body ) ? 1 : 0, body_types => $body ? [ sort keys( %{ $body->{content} // {} } ) ] : undef, }; } =head2 match($method, $path) = HASHREF The route of a request, or undef when no template matches $path. The route holds C