Jelajahi Sumber

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 hari lalu
induk
melakukan
5eb7d55926
6 mengubah file dengan 178 tambahan dan 83 penghapusan
  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
       body schema says additionalProperties.  An operation in CGI mode is
       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
 
-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`.
 
-## 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.
 
 ## 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
 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
-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
 variable matches a newline.
 
@@ -457,9 +459,9 @@ The `x-tpsgi-` keys of an operation are tPSGI's:
 ```perl
 x-tpsgi-callback  The sub that answers the request: a CODE reference, or
                   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
-                  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
@@ -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
 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
 operation with `x-tpsgi-env` is not validated, and its callback finds the
@@ -497,9 +497,8 @@ sub route {
     $self->ERROR("eeee");
     $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 = ...;
 

+ 26 - 30
lib/TPSGI.pm

@@ -926,10 +926,10 @@ sub appears_executable {
 
 =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>.
 
 =cut
@@ -944,15 +944,12 @@ sub route {
     my $cgi_mode  = $operation->{'x-tpsgi-env'};
     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.
-    $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
     $query->{last_fetched} = $request{last_fetch};
@@ -1015,17 +1012,19 @@ sub route {
     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.
 
 =cut
 
-sub extract_query {
+sub extract_input {
     my ( $self, $path, $route, $env ) = @_;
 
     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
 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
-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
 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
                       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
-                      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
 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
 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
 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->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 = ...;
 

+ 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};
 
-# 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,
@@ -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
 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
 
 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};
 
             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} = {
                 router    => $name,
                 template  => $template,
@@ -171,7 +173,7 @@ sub add_document {
                 operation => $operation,
                 schema    => $schema,
                 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
 
 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
 
@@ -317,23 +320,55 @@ 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;
-    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.
-    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 {
-        query_names => \%declared,
+        path_names  => $in{path},
+        query_names => $in{query},
         body_types  => $body ? \@types : undef,
         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
 
 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
 
-=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 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
 
@@ -423,13 +460,15 @@ sub validate {
         return ( 415, "/body: '$type' is not one of @{ $route->{body_types} }" ) unless $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;
+
+        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};
 
-    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;
 
     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' => {
             post => operation(
                 requestBody => {
@@ -211,7 +217,8 @@ subtest 'validation' => sub {
     ( $status, @errors ) = $router->validate( $route, query => { limit => 1, junk => 1 } );
     is( $status, 400, 'an undeclared query key is a 400' );
     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 } );
     is( $status, 400, 'and so is any key of the query, for an operation that declares nothing' );
     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( $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 {

+ 31 - 8
t/TPSGI.t

@@ -186,6 +186,21 @@ subtest 'new' => sub {
                         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' => {
                     post => {
                         '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' );
 };
 
-subtest 'extract_query' => sub {
+subtest 'extract_input' => sub {
     my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
     my $env   = { REQUEST_METHOD => 'GET' };
     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->{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->{slug},  'x', 'beside the path parameter' );
 
     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( $res->[0], 400,     'a 400' );
     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' );
 
-    $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' );
 };
 
-subtest 'extract_query, with a body' => sub {
+subtest 'extract_input, with a body' => sub {
     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 %env   = (
@@ -237,11 +252,19 @@ subtest 'extract_query, with a body' => sub {
 
     local @Test::Logger::Capture::lines;
     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: $!";
     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 {
     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' } );