Przeglądaj źródła

Give a callback the input of a request apart from the rest of its query

extract_query() merged the query string, the body, the path and
x-tpsgi-data into the same hash as everything tPSGI knows of the request:
the route, the method, the domain, the dispatcher.  Validation now refuses
an undeclared name, but a declared one could still stand in for a key that
tPSGI or the application set, and an application that hands the query to a
template hands the request every variable of the template.  @reserved
papered over part of it, by letting the query string carry the keys that
route() then overwrote.

Now extract_input() builds the input alone, and route() puts it in
$query->{input}.  The rest of the query is what tPSGI sets, and no request
can reach it, so @reserved goes: a key such as route is a 400 like any
other undeclared key.

The input is flat, so a name can mean one thing only.  new() dies on an
operation that gives one name to two of the path, the query and the body,
and on x-tpsgi-data that sets a name the request can give.  A body schema
that says additionalProperties can still take any name, so validate()
refuses a field or file of such a body whose name is a path variable or a
query parameter.  In CGI mode the input is empty, as the query was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
troglodyne-bot 3 dni temu
rodzic
commit
5eb7d55926
6 zmienionych plików z 178 dodań i 83 usunięć
  1. 5 0
      Changes
  2. 21 22
      README.md
  3. 26 30
      lib/TPSGI.pm
  4. 61 22
      lib/TPSGI/Router.pm
  5. 34 1
      t/TPSGI-Router.t
  6. 31 8
      t/TPSGI.t

+ 5 - 0
Changes

@@ -11,3 +11,8 @@ Revision history for TPSGI
       each key of the query, and each field and file of the body, unless a
       each key of the query, and each field and file of the body, unless a
       body schema says additionalProperties.  An operation in CGI mode is
       body schema says additionalProperties.  An operation in CGI mode is
       not validated.
       not validated.
+    - A callback finds the input of a request in $query->{input}: the query
+      string, the body, the path and x-tpsgi-data.  The rest of the query is
+      what tPSGI knows of the request, which the input cannot replace.  A
+      router dies as it loads when an operation gives one name to two of
+      them.

+ 21 - 22
README.md

@@ -189,18 +189,20 @@ and it has no extension or one of `cgi`, `sh`, `exe`, `pl`, `php` and `py`.
 
 
 ## route($route, $env, %request) = PSGI response
 ## route($route, $env, %request) = PSGI response
 
 
-Run the callback of $route, which TPSGI::Router::match() returned, with the
-query that extract\_query() builds.  In CGI mode, which `x-tpsgi-env` asks
-for, the query is not built, and the callback gets the request as %ENV
-instead.  respond() calls route(), with %request holding `fullpath`, `path`,
+Run the callback of $route, which TPSGI::Router::match() returned, with a
+query of what tPSGI knows of the request.  The query's `input` is what
+extract\_input() builds.  In CGI mode, which `x-tpsgi-env` asks for, the input
+is empty, and the callback gets the request as %ENV instead.  respond() calls route(), with %request holding `fullpath`, `path`,
 `start`, `last_fetch`, `deflate`, `domain` and `port`.
 `start`, `last_fetch`, `deflate`, `domain` and `port`.
 
 
-## extract\_query($path, $route, $env) = HASHREF
+## extract\_input($path, $route, $env) = HASHREF
 
 
-The query a route's callback is given: the query string, then the POST body,
-then the path parameters of $route and the operation's `x-tpsgi-data`, each
-over the last.  First TPSGI::Router::validate() checks the request against the
-operation.  A request that fails gets unsupported() for a 415 and badrequest()
+The input of a request, which route() gives the callback as the `input` of its
+query: the query string, the fields and files of the POST body, the path
+parameters of $route and the operation's `x-tpsgi-data`.  No two of them share
+a name, except that `x-tpsgi-data` is merged over a body whose schema says
+`additionalProperties`.  First TPSGI::Router::validate() checks the request
+against the operation.  A request that fails gets unsupported() for a 415 and badrequest()
 for a 400, and the reasons go to the log at the debug level.
 for a 400, and the reasons go to the log at the debug level.
 
 
 ## cgi($env, $fullpath, $file\_actual, $last\_fetch) = PSGI response
 ## cgi($env, $fullpath, $file\_actual, $last\_fetch) = PSGI response
@@ -436,7 +438,7 @@ and not at the first request.  A router that still defines `@routes` and no
 Each key of `paths` is a template.  `{name}` matches any text up to the next
 Each key of `paths` is a template.  `{name}` matches any text up to the next
 slash, and one segment can hold several variables and literal text, as in
 slash, and one segment can hold several variables and literal text, as in
 `{map}.xml`.  `{name+}` matches the rest of the path, slashes and all, and it
 `{map}.xml`.  `{name+}` matches the rest of the path, slashes and all, and it
-must be the whole last segment.  Its parameter is named `name+`, and the query
+must be the whole last segment.  Its parameter is named `name+`, and the input
 gives its value as `name`.  Any other character matches only itself.  No
 gives its value as `name`.  Any other character matches only itself.  No
 variable matches a newline.
 variable matches a newline.
 
 
@@ -457,9 +459,9 @@ The `x-tpsgi-` keys of an operation are tPSGI's:
 ```perl
 ```perl
 x-tpsgi-callback  The sub that answers the request: a CODE reference, or
 x-tpsgi-callback  The sub that answers the request: a CODE reference, or
                   the fully qualified name of a sub.  Required.
                   the fully qualified name of a sub.  Required.
-x-tpsgi-data      A HASH, which is merged over the query.
+x-tpsgi-data      A HASH, which is merged over the input.
 x-tpsgi-env       True for CGI mode: the callback gets the request as a
 x-tpsgi-env       True for CGI mode: the callback gets the request as a
-                  CGI %ENV, and no query is built.
+                  CGI %ENV, and its input is empty.
 ```
 ```
 
 
 Any other `x-` key is the application's.  The callback finds the whole
 Any other `x-` key is the application's.  The callback finds the whole
@@ -474,13 +476,11 @@ integer arrives as a number.  A request that fails gets a 400, and the reasons
 go to the log at the debug level, not into the page.  A body whose media type
 go to the log at the debug level, not into the page.  A body whose media type
 is not among the `content` of the `requestBody` gets a 415.
 is not among the `content` of the `requestBody` gets a 415.
 
 
-Every name in the request must be declared, because each one reaches the
-callback as a key of its query, where it can stand in for a key that the
-callback meant to set itself.  So the 400 is also the answer to a query key
-that is not a query parameter, to a body sent to an operation that has no
-`requestBody`, and to a field or a file that is not a property of the body's
-schema.  A schema that says `additionalProperties`
-decides for itself which other properties it takes.
+Every name in the request must be declared.  So the 400 is also the answer to
+a query key that is not a query parameter, to a body sent to an operation that
+has no `requestBody`, and to a field or a file that is not a property of the
+body's schema.  A schema that says `additionalProperties` decides for itself
+which other properties it takes.
 
 
 CGI mode is the exception, for a CGI that reads whatever it is sent.  An
 CGI mode is the exception, for a CGI that reads whatever it is sent.  An
 operation with `x-tpsgi-env` is not validated, and its callback finds the
 operation with `x-tpsgi-env` is not validated, and its callback finds the
@@ -497,9 +497,8 @@ sub route {
     $self->ERROR("eeee");
     $self->ERROR("eeee");
     $self->DEBUG("blah");
     $self->DEBUG("blah");
 
 
-    # From the query string, the POST body, the path or x-tpsgi-data, in
-    # that order: a later one wins.
-    my $param = $query->{param};
+    # From the query string, the POST body, the path or x-tpsgi-data.
+    my $param = $query->{input}{param};
 
 
     my %headers = ...;
     my %headers = ...;
 
 

+ 26 - 30
lib/TPSGI.pm

@@ -926,10 +926,10 @@ sub appears_executable {
 
 
 =head2 route($route, $env, %request) = PSGI response
 =head2 route($route, $env, %request) = PSGI response
 
 
-Run the callback of $route, which TPSGI::Router::match() returned, with the
-query that extract_query() builds.  In CGI mode, which C<x-tpsgi-env> asks
-for, the query is not built, and the callback gets the request as %ENV
-instead.  respond() calls route(), with %request holding C<fullpath>, C<path>,
+Run the callback of $route, which TPSGI::Router::match() returned, with a
+query of what tPSGI knows of the request.  The query's C<input> is what
+extract_input() builds.  In CGI mode, which C<x-tpsgi-env> asks for, the input
+is empty, and the callback gets the request as %ENV instead.  respond() calls route(), with %request holding C<fullpath>, C<path>,
 C<start>, C<last_fetch>, C<deflate>, C<domain> and C<port>.
 C<start>, C<last_fetch>, C<deflate>, C<domain> and C<port>.
 
 
 =cut
 =cut
@@ -944,15 +944,12 @@ sub route {
     my $cgi_mode  = $operation->{'x-tpsgi-env'};
     my $cgi_mode  = $operation->{'x-tpsgi-env'};
     my $streaming = $env->{'psgi.streaming'};
     my $streaming = $env->{'psgi.streaming'};
 
 
-    # Build the query data for passing to a route.
-    # GET, POST, path parameters, then explicit data overrides.
-    my $query = {};
-
     # If we're running in CGI mode, leave everything as-is.
     # If we're running in CGI mode, leave everything as-is.
-    $query = $self->extract_query( $path, $route, $env ) unless $cgi_mode;
+    my $input = $cgi_mode ? {} : $self->extract_input( $path, $route, $env );
 
 
-    # We failed validation if $query isn't a hashref.
-    return $query if ref $query eq 'ARRAY';
+    # We failed validation if $input isn't a hashref.
+    return $input if ref $input eq 'ARRAY';
+    my $query = { input => $input };
 
 
     # allow this stuff to survive down to the end of some routes
     # allow this stuff to survive down to the end of some routes
     $query->{last_fetched} = $request{last_fetch};
     $query->{last_fetched} = $request{last_fetch};
@@ -1015,17 +1012,19 @@ sub route {
     return $output;
     return $output;
 }
 }
 
 
-=head2 extract_query($path, $route, $env) = HASHREF
+=head2 extract_input($path, $route, $env) = HASHREF
 
 
-The query a route's callback is given: the query string, then the POST body,
-then the path parameters of $route and the operation's C<x-tpsgi-data>, each
-over the last.  First TPSGI::Router::validate() checks the request against the
-operation.  A request that fails gets unsupported() for a 415 and badrequest()
+The input of a request, which route() gives the callback as the C<input> of its
+query: the query string, the fields and files of the POST body, the path
+parameters of $route and the operation's C<x-tpsgi-data>.  No two of them share
+a name, except that C<x-tpsgi-data> is merged over a body whose schema says
+C<additionalProperties>.  First TPSGI::Router::validate() checks the request
+against the operation.  A request that fails gets unsupported() for a 415 and badrequest()
 for a 400, and the reasons go to the log at the debug level.
 for a 400, and the reasons go to the log at the debug level.
 
 
 =cut
 =cut
 
 
-sub extract_query {
+sub extract_input {
     my ( $self, $path, $route, $env ) = @_;
     my ( $self, $path, $route, $env ) = @_;
 
 
     my $query = {};
     my $query = {};
@@ -1778,7 +1777,7 @@ C<%openapi> dies the same way.
 Each key of C<paths> is a template.  C<{name}> matches any text up to the next
 Each key of C<paths> is a template.  C<{name}> matches any text up to the next
 slash, and one segment can hold several variables and literal text, as in
 slash, and one segment can hold several variables and literal text, as in
 C<{map}.xml>.  C<{name+}> matches the rest of the path, slashes and all, and it
 C<{map}.xml>.  C<{name+}> matches the rest of the path, slashes and all, and it
-must be the whole last segment.  Its parameter is named C<name+>, and the query
+must be the whole last segment.  Its parameter is named C<name+>, and the input
 gives its value as C<name>.  Any other character matches only itself.  No
 gives its value as C<name>.  Any other character matches only itself.  No
 variable matches a newline.
 variable matches a newline.
 
 
@@ -1798,9 +1797,9 @@ The C<x-tpsgi-> keys of an operation are tPSGI's:
 
 
     x-tpsgi-callback  The sub that answers the request: a CODE reference, or
     x-tpsgi-callback  The sub that answers the request: a CODE reference, or
                       the fully qualified name of a sub.  Required.
                       the fully qualified name of a sub.  Required.
-    x-tpsgi-data      A HASH, which is merged over the query.
+    x-tpsgi-data      A HASH, which is merged over the input.
     x-tpsgi-env       True for CGI mode: the callback gets the request as a
     x-tpsgi-env       True for CGI mode: the callback gets the request as a
-                      CGI %ENV, and no query is built.
+                      CGI %ENV, and its input is empty.
 
 
 Any other C<x-> key is the application's.  The callback finds the whole
 Any other C<x-> key is the application's.  The callback finds the whole
 operation in C<< $query->{dispatcher} >>, and the template that matched in
 operation in C<< $query->{dispatcher} >>, and the template that matched in
@@ -1814,13 +1813,11 @@ integer arrives as a number.  A request that fails gets a 400, and the reasons
 go to the log at the debug level, not into the page.  A body whose media type
 go to the log at the debug level, not into the page.  A body whose media type
 is not among the C<content> of the C<requestBody> gets a 415.
 is not among the C<content> of the C<requestBody> gets a 415.
 
 
-Every name in the request must be declared, because each one reaches the
-callback as a key of its query, where it can stand in for a key that the
-callback meant to set itself.  So the 400 is also the answer to a query key
-that is not a query parameter, to a body sent to an operation that has no
-C<requestBody>, and to a field or a file that is not a property of the body's
-schema.  A schema that says C<additionalProperties>
-decides for itself which other properties it takes.
+Every name in the request must be declared.  So the 400 is also the answer to
+a query key that is not a query parameter, to a body sent to an operation that
+has no C<requestBody>, and to a field or a file that is not a property of the
+body's schema.  A schema that says C<additionalProperties> decides for itself
+which other properties it takes.
 
 
 CGI mode is the exception, for a CGI that reads whatever it is sent.  An
 CGI mode is the exception, for a CGI that reads whatever it is sent.  An
 operation with C<x-tpsgi-env> is not validated, and its callback finds the
 operation with C<x-tpsgi-env> is not validated, and its callback finds the
@@ -1836,9 +1833,8 @@ A callback is passed the TPSGI object and the query, which means you can do:
         $self->ERROR("eeee");
         $self->ERROR("eeee");
         $self->DEBUG("blah");
         $self->DEBUG("blah");
 
 
-        # From the query string, the POST body, the path or x-tpsgi-data, in
-        # that order: a later one wins.
-        my $param = $query->{param};
+        # From the query string, the POST body, the path or x-tpsgi-data.
+        my $param = $query->{input}{param};
 
 
         my %headers = ...;
         my %headers = ...;
 
 

+ 61 - 22
lib/TPSGI/Router.pm

@@ -62,10 +62,6 @@ order of a hash.
 
 
 my @methods = qw{get put post delete options head patch trace};
 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.
 # How concrete a segment is.  A lower rank wins.
 my %rank = (
 my %rank = (
     literal => 0,
     literal => 0,
@@ -86,6 +82,10 @@ operation has no C<x-tpsgi-callback>, 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
 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.
 of their variables, and on one template and method in two documents.
 
 
+The path, the query and the body of a request share one input, so new() also
+dies on an operation that gives one name to two of them, or whose
+C<x-tpsgi-data> sets a name that the request can give.
+
 =cut
 =cut
 
 
 sub new {
 sub new {
@@ -164,6 +164,8 @@ sub add_document {
             die "$name: \U$method\E $template is also a route of $matcher->{methods}{$method}{router}\n" if $matcher->{methods}{$method};
             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};
             my $operation = $paths->{$template}{$method};
+            my $rules     = $self->rules_for( $schema, $method, $template );
+            check_names( "$name: \U$method\E $template", $operation, $rules );
             $matcher->{methods}{$method} = {
             $matcher->{methods}{$method} = {
                 router    => $name,
                 router    => $name,
                 template  => $template,
                 template  => $template,
@@ -171,7 +173,7 @@ sub add_document {
                 operation => $operation,
                 operation => $operation,
                 schema    => $schema,
                 schema    => $schema,
                 callback  => resolve_callback( "$name: \U$method\E $template", $operation->{'x-tpsgi-callback'} ),
                 callback  => resolve_callback( "$name: \U$method\E $template", $operation->{'x-tpsgi-callback'} ),
-                %{ $self->rules_for( $schema, $method, $template ) },
+                %$rules,
             };
             };
         }
         }
     }
     }
@@ -307,9 +309,10 @@ sub resolve_callback {
 =head2 rules_for($schema, $method, $template) = HASHREF
 =head2 rules_for($schema, $method, $template) = HASHREF
 
 
 What validate() checks of one operation beyond its schema: the names of its
 What validate() checks of one operation beyond its schema: the names of its
-query parameters, the media types of its request body, and for each media type
-the names of the properties that its schema takes.  A schema that says
-C<additionalProperties> decides for itself, so it has no names here.
+path variables and its query parameters, the media types of its request body,
+and for each media type the names of the properties that its schema names.
+C<closed> holds the media types whose schema does not say
+C<additionalProperties>, which take no other property.
 
 
 =cut
 =cut
 
 
@@ -317,23 +320,55 @@ sub rules_for {
     my ( $self, $schema, $method, $template ) = @_;
     my ( $self, $schema, $method, $template ) = @_;
 
 
     my @parameters = @{ $schema->parameters_for_request( [ $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;
-    my @types      = $body ? sort keys( %{ $body->{content} // {} } ) : ();
+    my %in         = map {
+        my $in = $_;
+        ( $in => { map { ( $_->{name} =~ s/ [+] \z //r ) => 1 } grep { $_->{in} eq $in } @parameters } )
+    } qw{path query};
+    my ($body) = grep { $_->{in} eq 'body' } @parameters;
+    my @types = $body ? sort keys( %{ $body->{content} // {} } ) : ();
 
 
     # get() rather than the content itself, because get() follows a $ref.
     # get() rather than the content itself, because get() follows a $ref.
-    my %body_names = map {
-        my $of = $schema->get( [ 'paths', $template, $method, 'requestBody', 'content', $_, 'schema' ] ) // {};
-        exists $of->{additionalProperties} ? () : ( $_ => { map { $_ => 1 } keys( %{ $of->{properties} // {} } ) } );
-    } @types;
+    my ( %body_names, %closed );
+    foreach my $type (@types) {
+        my $of = $schema->get( [ 'paths', $template, $method, 'requestBody', 'content', $type, 'schema' ] ) // {};
+        $body_names{$type} = { map { $_ => 1 } keys( %{ $of->{properties} // {} } ) };
+        $closed{$type}     = 1 unless exists $of->{additionalProperties};
+    }
 
 
     return {
     return {
-        query_names => \%declared,
+        path_names  => $in{path},
+        query_names => $in{query},
         body_types  => $body ? \@types : undef,
         body_types  => $body ? \@types : undef,
         body_names  => \%body_names,
         body_names  => \%body_names,
+        closed      => \%closed,
     };
     };
 }
 }
 
 
+=head2 check_names($what, $operation, $rules)
+
+A function.  Die, and name $what, when the rules_for() of $operation give one
+name to two of the path, the query and the body, or when its C<x-tpsgi-data>
+sets a name that one of them declares.
+
+=cut
+
+sub check_names {
+    my ( $what, $operation, $rules ) = @_;
+
+    my %where;
+    push( @{ $where{$_} }, 'the path' )  for keys( %{ $rules->{path_names} } );
+    push( @{ $where{$_} }, 'the query' ) for keys( %{ $rules->{query_names} } );
+    push( @{ $where{$_} }, 'the body' )  for List::Util::uniq( map { keys(%$_) } values( %{ $rules->{body_names} } ) );
+
+    my @twice = grep { @{ $where{$_} } > 1 } sort keys(%where);
+    die "$what: $_ is a name of " . join( ' and of ', @{ $where{$_} } ) . "\n" for @twice;
+
+    my $data  = $operation->{'x-tpsgi-data'};
+    my @fixed = ref $data eq 'HASH' ? grep { $where{$_} } sort keys(%$data) : ();
+    die "$what: x-tpsgi-data sets $_, which is a name of $where{$_}[0]\n" for @fixed;
+    return;
+}
+
 =head2 match($method, $path) = HASHREF
 =head2 match($method, $path) = HASHREF
 
 
 The route of a request, or undef when no template matches $path.
 The route of a request, or undef when no template matches $path.
@@ -391,13 +426,15 @@ holds a name that the operation does not declare:
 
 
 =over
 =over
 
 
-=item a key of the query that is not a query parameter, unless tPSGI sets
-that key itself;
+=item a key of the query that is not a query parameter;
 
 
 =item any field or file, when the operation has no C<requestBody>;
 =item any field or file, when the operation has no C<requestBody>;
 
 
 =item a field or file that is not a property of the schema for its media type,
 =item a field or file that is not a property of the schema for its media type,
-unless that schema says C<additionalProperties>.
+unless that schema says C<additionalProperties>;
+
+=item a field or file that a schema with C<additionalProperties> takes, whose
+name is a path variable or a query parameter.
 
 
 =back
 =back
 
 
@@ -423,13 +460,15 @@ sub validate {
         return ( 415, "/body: '$type' is not one of @{ $route->{body_types} }" ) unless $actual;
         return ( 415, "/body: '$type' is not one of @{ $route->{body_types} }" ) unless $actual;
 
 
         my $names = $route->{body_names}{$actual};
         my $names = $route->{body_names}{$actual};
-        my @extra = $names ? grep { !$names->{$_} } @sent : ();
+        my @extra = $route->{closed}{$actual} ? grep { !$names->{$_} } @sent : ();
         return ( 400, map { "/body/$_: the schema of $actual does not declare this property" } @extra ) if @extra;
         return ( 400, map { "/body/$_: the schema of $actual does not declare this property" } @extra ) if @extra;
+
+        my @taken = grep { $route->{path_names}{$_} || $route->{query_names}{$_} } @sent;
+        return ( 400, map { "/body/$_: the path or the query declares this name" } @taken ) if @taken;
     }
     }
     return ( 400, map { "/body/$_: the operation takes no body" } @sent ) if @sent && !$route->{body_types};
     return ( 400, map { "/body/$_: the operation takes no body" } @sent ) if @sent && !$route->{body_types};
 
 
-    my %known      = ( %{ $route->{query_names} }, map { $_ => 1 } @reserved );
-    my @undeclared = grep { !$known{$_} } sort keys(%$query);
+    my @undeclared = grep { !$route->{query_names}{$_} } sort keys(%$query);
     return ( 400, map { "/$_: the operation does not declare this parameter" } @undeclared ) if @undeclared;
     return ( 400, map { "/$_: the operation does not declare this parameter" } @undeclared ) if @undeclared;
 
 
     my %cookies = map { split( m/=/, $_, 2 ) } split( m/ ; [ ]* /, $env->{HTTP_COOKIE} // '' );
     my %cookies = map { split( m/=/, $_, 2 ) } split( m/ ; [ ]* /, $env->{HTTP_COOKIE} // '' );

+ 34 - 1
t/TPSGI-Router.t

@@ -78,6 +78,12 @@ my $router = TPSGI::Router->new(
                 },
                 },
             ),
             ),
         },
         },
+        '/open/{slug}' => {
+            post => operation(
+                parameters  => [ path_parameter('slug') ],
+                requestBody => { content => { 'application/x-www-form-urlencoded' => { schema => { type => 'object', additionalProperties => true } } } },
+            ),
+        },
         '/open' => {
         '/open' => {
             post => operation(
             post => operation(
                 requestBody => {
                 requestBody => {
@@ -211,7 +217,8 @@ subtest 'validation' => sub {
     ( $status, @errors ) = $router->validate( $route, query => { limit => 1, junk => 1 } );
     ( $status, @errors ) = $router->validate( $route, query => { limit => 1, junk => 1 } );
     is( $status, 400, 'an undeclared query key is a 400' );
     is( $status, 400, 'an undeclared query key is a 400' );
     like( \@errors, [qr/\/junk:/], 'and named' );
     like( \@errors, [qr/\/junk:/], 'and named' );
-    is( [ $router->validate( $route, query => { limit => 1, route => '/x' } ) ], [], 'except a key that tPSGI sets itself' );
+    ( $status, @errors ) = $router->validate( $route, query => { limit => 1, route => '/x' } );
+    is( $status, 400, 'even one that tPSGI sets in the query itself, since the input is apart from it' );
     ( $status, @errors ) = $router->validate( $router->match( 'GET', '/loose' ), query => { anything => 1 } );
     ( $status, @errors ) = $router->validate( $router->match( 'GET', '/loose' ), query => { anything => 1 } );
     is( $status, 400, 'and so is any key of the query, for an operation that declares nothing' );
     is( $status, 400, 'and so is any key of the query, for an operation that declares nothing' );
     like( \@errors, [qr/\/anything:/], 'named too' );
     like( \@errors, [qr/\/anything:/], 'named too' );
@@ -255,6 +262,32 @@ subtest 'the names of a body' => sub {
     is( [ $router->validate( $route, body => { count => 3 }, content_type => $form_type ) ], [], 'and does name the count' );
     is( [ $router->validate( $route, body => { count => 3 }, content_type => $form_type ) ], [], 'and does name the count' );
 
 
     is( [ $router->validate( $router->match( 'POST', '/open' ), body => { count => 3, junk => 1 }, content_type => $form_type ) ], [], 'a schema with additionalProperties takes what it says it takes' );
     is( [ $router->validate( $router->match( 'POST', '/open' ), body => { count => 3, junk => 1 }, content_type => $form_type ) ], [], 'a schema with additionalProperties takes what it says it takes' );
+    ( $status, @errors ) = $router->validate( $router->match( 'POST', '/open/x' ), body => { slug => 'y' }, content_type => $form_type );
+    is( $status, 400, 'but not a name that the path or the query declares' );
+    like( \@errors, [qr/\/body\/slug:[ ]the[ ]path[ ]or[ ]the[ ]query/], 'for that reason' );
+};
+
+subtest 'one name in two places' => sub {
+    my $form  = { content => { 'application/x-www-form-urlencoded' => { schema => { type => 'object', properties => { limit => { type => 'integer' } } } } } };
+    my $query = { name    => 'limit', in => 'query', schema => { type => 'integer' } };
+    my $limit = qr/limit[ ]is[ ]a[ ]name[ ]of/;
+
+    like(
+        dies { TPSGI::Router->new( 'Test.pm' => document( '/a' => { post => operation( parameters => [$query], requestBody => $form ) } ) ) },
+        qr/POST[ ]\/a:[ ]$limit[ ]the[ ]query[ ]and[ ]of[ ]the[ ]body/,
+        'the query and the body die'
+    );
+    like(
+        dies { TPSGI::Router->new( 'Test.pm' => document( '/a/{limit}' => { get => operation( parameters => [ $query, path_parameter('limit') ] ) } ) ) },
+        qr/$limit[ ]the[ ]path[ ]and[ ]of[ ]the[ ]query/,
+        'and so do the path and the query'
+    );
+    like(
+        dies { TPSGI::Router->new( 'Test.pm' => document( '/a' => { get => operation( parameters => [$query], 'x-tpsgi-data' => { limit => 1 } ) } ) ) },
+        qr/x-tpsgi-data[ ]sets[ ]limit,[ ]which[ ]is[ ]a[ ]name/,
+        'and x-tpsgi-data that sets a name the request can give'
+    );
+    ok( lives { TPSGI::Router->new( 'Test.pm' => document( '/a' => { get => operation( parameters => [$query], 'x-tpsgi-data' => { other => 1 } ) } ) ) }, 'but not x-tpsgi-data of another name' ) or note $@;
 };
 };
 
 
 subtest 'precedence' => sub {
 subtest 'precedence' => sub {

+ 31 - 8
t/TPSGI.t

@@ -186,6 +186,21 @@ subtest 'new' => sub {
                         responses          => { 200 => { description => 'ok' } },
                         responses          => { 200 => { description => 'ok' } },
                     },
                     },
                 },
                 },
+                '/echo/{slug}' => {
+                    get => {
+                        'x-tpsgi-callback' => sub {
+                            my ( $tpsgi, $query ) = @_;
+                            my $input = join( ',', map { "$_=$query->{input}{$_}" } sort keys( %{ $query->{input} } ) );
+                            return [ 200, [], [ $input, join( ',', grep { exists $query->{$_} } qw{slug limit from} ) ] ];
+                        },
+                        'x-tpsgi-data' => { from => 'data' },
+                        parameters     => [
+                            { name => 'slug',  in => 'path',  required => true, schema => { type => 'string' } },
+                            { name => 'limit', in => 'query', schema   => { type => 'integer' } },
+                        ],
+                        responses => { 200 => { description => 'ok' } },
+                    },
+                },
                 '/plain' => {
                 '/plain' => {
                     post => {
                     post => {
                         'x-tpsgi-callback' => sub { return [ 200, [], ['ok'] ] },
                         'x-tpsgi-callback' => sub { return [ 200, [], ['ok'] ] },
@@ -200,33 +215,33 @@ subtest 'new' => sub {
     ref_is( TPSGI->new( %new_options, routers => [$router] )->routes, $first->routes, 'and the next request in the worker reuses the router' );
     ref_is( TPSGI->new( %new_options, routers => [$router] )->routes, $first->routes, 'and the next request in the worker reuses the router' );
 };
 };
 
 
-subtest 'extract_query' => sub {
+subtest 'extract_input' => sub {
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $env   = { REQUEST_METHOD => 'GET' };
     my $env   = { REQUEST_METHOD => 'GET' };
     my $cafe  = "caf\N{LATIN SMALL LETTER E WITH ACUTE}";
     my $cafe  = "caf\N{LATIN SMALL LETTER E WITH ACUTE}";
 
 
-    my $query = $tpsgi->extract_query( "/post/$cafe", $tpsgi->routes->match( 'GET', "/post/$cafe" ), $env );
+    my $query = $tpsgi->extract_input( "/post/$cafe", $tpsgi->routes->match( 'GET', "/post/$cafe" ), $env );
     is( $query->{slug}, $cafe,  'a path parameter, outside ASCII too, is in the query' );
     is( $query->{slug}, $cafe,  'a path parameter, outside ASCII too, is in the query' );
     is( $query->{from}, 'data', 'and so is x-tpsgi-data' );
     is( $query->{from}, 'data', 'and so is x-tpsgi-data' );
 
 
-    $query = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'limit=5' } );
+    $query = $tpsgi->extract_input( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'limit=5' } );
     is( $query->{limit}, 5,   'the query string is in the query' );
     is( $query->{limit}, 5,   'the query string is in the query' );
     is( $query->{slug},  'x', 'beside the path parameter' );
     is( $query->{slug},  'x', 'beside the path parameter' );
 
 
     local @Test::Logger::Capture::lines;
     local @Test::Logger::Capture::lines;
-    my $res = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'limit=many' } );
+    my $res = $tpsgi->extract_input( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'limit=many' } );
     is( ref $res,  'ARRAY', 'a query that fails validation is an answer, not a query' );
     is( ref $res,  'ARRAY', 'a query that fails validation is an answer, not a query' );
     is( $res->[0], 400,     'a 400' );
     is( $res->[0], 400,     'a 400' );
     unlike( $res->[2][0], qr/integer/, 'which does not say why' );
     unlike( $res->[2][0], qr/integer/, 'which does not say why' );
 
 
-    $res = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'junk=1' } );
+    $res = $tpsgi->extract_input( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'junk=1' } );
     is( $res->[0], 400, 'and so is one with a key that the operation does not declare' );
     is( $res->[0], 400, 'and so is one with a key that the operation does not declare' );
 
 
-    $res = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'slug=y' } );
+    $res = $tpsgi->extract_input( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'slug=y' } );
     is( $res->[0], 400, 'even when the path declares it' );
     is( $res->[0], 400, 'even when the path declares it' );
 };
 };
 
 
-subtest 'extract_query, with a body' => sub {
+subtest 'extract_input, with a body' => sub {
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $part  = qq{--x\r\nContent-Disposition: form-data; name="file"; filename="f.txt"\r\nContent-Type: text/plain\r\n\r\nhi\r\n--x--\r\n};
     my $part  = qq{--x\r\nContent-Disposition: form-data; name="file"; filename="f.txt"\r\nContent-Type: text/plain\r\n\r\nhi\r\n--x--\r\n};
     my %env   = (
     my %env   = (
@@ -237,11 +252,19 @@ subtest 'extract_query, with a body' => sub {
 
 
     local @Test::Logger::Capture::lines;
     local @Test::Logger::Capture::lines;
     open( my $input, '<', \$part ) or die "Cannot open a scalar: $!";
     open( my $input, '<', \$part ) or die "Cannot open a scalar: $!";
-    my $res = $tpsgi->extract_query( '/plain', $tpsgi->routes->match( 'POST', '/plain' ), { %env, 'psgi.input' => $input } );
+    my $res = $tpsgi->extract_input( '/plain', $tpsgi->routes->match( 'POST', '/plain' ), { %env, 'psgi.input' => $input } );
     close($input) or die "Cannot close: $!";
     close($input) or die "Cannot close: $!";
     is( ref $res eq 'ARRAY' && $res->[0], 400, 'a file, to an operation with no requestBody, is a 400' );
     is( ref $res eq 'ARRAY' && $res->[0], 400, 'a file, to an operation with no requestBody, is a 400' );
 };
 };
 
 
+subtest 'route' => sub {
+    my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
+    my $res   = $tpsgi->respond( { REQUEST_METHOD => 'GET', PATH_INFO => '/echo/x', REQUEST_URI => '/echo/x?limit=5', QUERY_STRING => 'limit=5', HTTP_HOST => 'test.test' } );
+    is( $res->[0],    200,                        'answers' );
+    is( $res->[2][0], 'from=data,limit=5,slug=x', 'with the query, the path and x-tpsgi-data in the input of the query' );
+    is( $res->[2][1], '',                         'and none of them beside it' );
+};
+
 subtest 'route, in CGI mode' => sub {
 subtest 'route, in CGI mode' => sub {
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $res   = $tpsgi->respond( { REQUEST_METHOD => 'GET', PATH_INFO => '/cgi', REQUEST_URI => '/cgi?anything=1', QUERY_STRING => 'anything=1', HTTP_HOST => 'test.test' } );
     my $res   = $tpsgi->respond( { REQUEST_METHOD => 'GET', PATH_INFO => '/cgi', REQUEST_URI => '/cgi?anything=1', QUERY_STRING => 'anything=1', HTTP_HOST => 'test.test' } );