TPSGI-Router.t 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env perl
  2. use 5.040;
  3. use warnings FATAL => 'all';
  4. use re '/aasx';
  5. use Test2::V0;
  6. use Test2::Plugin::NoWarnings;
  7. use Mojo::JSON ();
  8. use Cpanel::JSON::XS ();
  9. use FindBin::libs;
  10. use TPSGI::Router;
  11. sub named { return 'named' }
  12. sub coderef { return 'coderef' }
  13. sub path_parameter {
  14. my ( $name, %schema ) = @_;
  15. return { name => $name, in => 'path', required => true, schema => { type => 'string', %schema } };
  16. }
  17. sub operation {
  18. my (%operation) = @_;
  19. return { 'x-tpsgi-callback' => \&coderef, responses => { 200 => { description => 'ok' } }, %operation };
  20. }
  21. sub document {
  22. my (%paths) = @_;
  23. return {
  24. openapi => '3.0.3',
  25. info => { title => 'test', version => '1' },
  26. components => { schemas => { Count => { type => 'object', properties => { count => { type => 'integer' } } } } },
  27. paths => \%paths,
  28. };
  29. }
  30. my $router = TPSGI::Router->new(
  31. 'Test.pm' => document(
  32. '/sitemap/static.xml' => { get => operation() },
  33. '/sitemap/{map}.xml' => { get => operation( parameters => [ path_parameter('map') ] ) },
  34. '/sitemap/{map}.xml.gz' => { get => operation( parameters => [ path_parameter('map') ] ) },
  35. '/lib/{module+}' => { get => operation( parameters => [ path_parameter('module+') ] ) },
  36. '/lib/index' => { get => operation() },
  37. '/{year}-{month}/{slug}' => { get => operation( parameters => [ map { path_parameter($_) } qw{year month slug} ] ) },
  38. '/humans.txt' => { get => operation( 'x-tpsgi-callback' => 'main::named' ) },
  39. '/post/{slug}' => { get => operation( parameters => [ path_parameter('slug') ] ) },
  40. '/form' => {
  41. post => operation(
  42. requestBody => {
  43. content => {
  44. 'application/x-www-form-urlencoded' => {
  45. schema => {
  46. type => 'object',
  47. additionalProperties => false,
  48. properties => { count => { type => 'integer' } },
  49. },
  50. },
  51. },
  52. },
  53. ),
  54. },
  55. '/upload' => {
  56. post => operation(
  57. requestBody => {
  58. content => {
  59. 'multipart/form-data' => {
  60. schema => {
  61. type => 'object',
  62. properties => { count => { type => 'integer' }, file => { type => 'string', format => 'binary' } },
  63. },
  64. },
  65. 'application/x-www-form-urlencoded' => { schema => { '$ref' => '#/components/schemas/Count' } },
  66. },
  67. },
  68. ),
  69. },
  70. '/open' => {
  71. post => operation(
  72. requestBody => {
  73. content => {
  74. 'application/x-www-form-urlencoded' => {
  75. schema => { type => 'object', additionalProperties => true, properties => { count => { type => 'integer' } } },
  76. },
  77. },
  78. },
  79. ),
  80. },
  81. '/search' => {
  82. get => operation(
  83. parameters => [
  84. { name => 'limit', in => 'query', schema => { type => 'integer' } },
  85. { name => 'exact', in => 'query', schema => { type => 'boolean' } },
  86. ],
  87. ),
  88. },
  89. '/loose' => { get => operation() },
  90. '/news/{slug}' => { get => operation( parameters => [ path_parameter('slug') ] ) },
  91. '/{section}/latest' => { get => operation( parameters => [ path_parameter('section') ] ) },
  92. ),
  93. );
  94. subtest 'a literal template wins over a variable' => sub {
  95. is( $router->match( 'GET', '/sitemap/static.xml' )->{template}, '/sitemap/static.xml', 'the static sitemap is the static route' );
  96. my $route = $router->match( 'GET', '/sitemap/1.xml' );
  97. is( $route->{template}, '/sitemap/{map}.xml', 'and any other reaches the template' );
  98. is( $route->{path}, { map => 1 }, 'with the variable' );
  99. is( $router->match( 'GET', '/sitemap/1.xml.gz' )->{template}, '/sitemap/{map}.xml.gz', 'and the compressed one its own template' );
  100. is( $router->match( 'GET', '/lib/index' )->{template}, '/lib/index', 'a literal path wins over {name+}' );
  101. is( $router->match( 'GET', '/news/latest' )->{template}, '/news/{slug}', 'and an earlier literal segment wins over more literal characters' );
  102. };
  103. subtest 'variables' => sub {
  104. my $route = $router->match( 'GET', '/2026-10/hello' );
  105. is( $route->{path}, { year => 2026, month => 10, slug => 'hello' }, 'two variables in one segment' );
  106. $route = $router->match( 'GET', '/lib/Trog/Auth.html' );
  107. is( $route->{path}, { module => 'Trog/Auth.html' }, '{module+} takes the rest of the path, slashes and all, as module' );
  108. my $cafe = "caf\N{LATIN SMALL LETTER E WITH ACUTE}";
  109. is( $router->match( 'GET', "/post/$cafe" )->{path}, { slug => $cafe }, 'a variable takes a letter outside ASCII' );
  110. is( $router->match( 'GET', "/post/a\nb" ), undef, 'and no newline' );
  111. is( $router->match( 'GET', '/post/a/b' ), undef, 'and no slash' );
  112. is( $router->match( 'GET', "/lib/a\nb" ), undef, 'nor does {name+} take a newline' );
  113. };
  114. subtest 'a literal character is only itself' => sub {
  115. is( $router->match( 'GET', '/humans.txt' )->{template}, '/humans.txt', 'the dot matches a dot' );
  116. is( $router->match( 'GET', '/humansXtxt' ), undef, 'and nothing else' );
  117. is( $router->match( 'GET', '/sitemap/1Xxml' ), undef, 'in a segment with a variable as well' );
  118. };
  119. subtest 'callbacks' => sub {
  120. is( $router->match( 'GET', '/loose' )->{callback}->(), 'coderef', 'a CODE reference is the callback' );
  121. is( $router->match( 'GET', '/humans.txt' )->{callback}->(), 'named', 'and a name is resolved to its sub' );
  122. like(
  123. dies { TPSGI::Router->new( 'Bad.pm' => document( '/x' => { get => { responses => { 200 => { description => 'ok' } } } } ) ) },
  124. qr/Bad[.]pm:[ ]GET[ ]\/x[ ]has[ ]no[ ]x-tpsgi-callback/,
  125. 'an operation without a callback dies'
  126. );
  127. like(
  128. dies { TPSGI::Router->new( 'Bad.pm' => document( '/x' => { get => operation( 'x-tpsgi-callback' => 'main::no_such_sub' ) } ) ) },
  129. qr/no_such_sub,[ ]which[ ]is[ ]not[ ]a[ ]defined/,
  130. 'and so does a name that is not a sub'
  131. );
  132. };
  133. subtest 'booleans' => sub {
  134. my $json = { %{ path_parameter('x') }, required => Cpanel::JSON::XS::true };
  135. ok(
  136. lives { TPSGI::Router->new( 'Json.pm' => document( '/j/{x}' => { get => operation( parameters => [$json] ) } ) ) },
  137. 'a JSON boolean is a boolean'
  138. ) or note $@;
  139. # $router's own document marks its path parameters required with perl's own true.
  140. is( $router->match( 'GET', '/post/a' )->{template}, '/post/{slug}', "and so is perl's own, which every document above uses" );
  141. my $document = document( '/n/{x}' => { get => operation( parameters => [ path_parameter('x') ] ) } );
  142. TPSGI::Router->new( 'Native.pm' => $document );
  143. ok( !ref $document->{paths}{'/n/{x}'}{get}{parameters}[0]{required}, 'and the router leaves the document it was given as it was' );
  144. };
  145. subtest 'documents that are refused' => sub {
  146. like(
  147. dies { TPSGI::Router->new( 'Bad.pm' => { openapi => '3.0.3', paths => {} } ) },
  148. qr/is[ ]not[ ]a[ ]valid[ ]OpenAPI[ ]3[ ]document/,
  149. 'an invalid document dies with the reason'
  150. );
  151. like(
  152. dies { TPSGI::Router->new( 'Bad.pm' => document( '/a/{x}' => { get => operation() }, '/a/{y}' => { post => operation() } ) ) },
  153. qr/[}][ ]and[ ]\/a\/[{].[}][ ]match[ ]the[ ]same/,
  154. 'two templates that differ only in their variable names die'
  155. );
  156. like(
  157. dies { TPSGI::Router->new( 'One.pm' => document( '/a' => { get => operation() } ), 'Two.pm' => document( '/a' => { get => operation() } ) ) },
  158. qr/Two[.]pm:[ ]GET[ ]\/a[ ]is[ ]also[ ]a[ ]route/,
  159. 'one template and method in two routers dies'
  160. );
  161. ok(
  162. lives { TPSGI::Router->new( 'One.pm' => document( '/a' => { get => operation() } ), 'Two.pm' => document( '/a' => { post => operation() } ) ) },
  163. 'but two methods of one template in two routers do not'
  164. ) or note $@;
  165. like( dies { TPSGI::Router::compile_template('/a/{x+}/b') }, qr/not[ ]the[ ]whole[ ]last[ ]segment/, '{name+} before the last segment dies' );
  166. like( dies { TPSGI::Router::compile_template('/a/b{x+}') }, qr/not[ ]the[ ]whole[ ]last[ ]segment/, 'and so does {name+} with text beside it' );
  167. like( dies { TPSGI::Router::compile_template('/a/{x') }, qr/a[ ]brace[ ]that[ ]is[ ]not/, 'and a brace that is not a variable' );
  168. };
  169. subtest 'methods' => sub {
  170. is( $router->match( 'HEAD', '/loose' )->{method}, 'get', 'HEAD falls back to get' );
  171. my $route = $router->match( 'POST', '/loose' );
  172. is( $route->{status}, 405, 'a path whose template has no operation for the method is a 405' );
  173. is( $route->{allow}, [qw{GET HEAD}], 'which names the methods it does have' );
  174. is( $router->match( 'GET', '/nowhere' ), undef, 'and a path that no template matches is nothing at all' );
  175. };
  176. subtest 'validation' => sub {
  177. my $route = $router->match( 'GET', '/search' );
  178. my %query = ( limit => '5', exact => 'true' );
  179. is( [ $router->validate( $route, query => \%query ) ], [], 'a valid query passes' );
  180. is( Mojo::JSON::encode_json( \%query ), '{"exact":true,"limit":5}', 'and arrives coerced: a number and a boolean, not two strings' );
  181. my ( $status, @errors ) = $router->validate( $route, query => { limit => 'many' } );
  182. is( $status, 400, 'a value that its schema refuses is a 400' );
  183. like( \@errors, [qr/\/limit:[ ]Expected[ ]integer/], 'for that reason' );
  184. ( $status, @errors ) = $router->validate( $route, query => { limit => 1, junk => 1 } );
  185. is( $status, 400, 'an undeclared query key is a 400' );
  186. like( \@errors, [qr/\/junk:/], 'and named' );
  187. is( [ $router->validate( $route, query => { limit => 1, route => '/x' } ) ], [], 'except a key that tPSGI sets itself' );
  188. ( $status, @errors ) = $router->validate( $router->match( 'GET', '/loose' ), query => { anything => 1 } );
  189. is( $status, 400, 'and so is any key of the query, for an operation that declares nothing' );
  190. like( \@errors, [qr/\/anything:/], 'named too' );
  191. is( [ $router->validate( $router->match( 'GET', '/loose' ), query => {} ) ], [], 'which takes an empty query' );
  192. $route = $router->match( 'POST', '/form' );
  193. my $form_type = 'application/x-www-form-urlencoded';
  194. is( [ $router->validate( $route, body => { count => '3' }, content_type => $form_type ) ], [], 'a form that its schema takes passes' );
  195. ( $status, @errors ) = $router->validate( $route, body => { count => 3, junk => 1 }, content_type => $form_type );
  196. is( $status, 400, 'a form key that additionalProperties refuses is a 400' );
  197. like( \@errors, [qr/Properties[ ]not[ ]allowed:[ ]junk/], 'for that reason' );
  198. ( $status, @errors ) = $router->validate( $route, body => { count => 3 }, content_type => 'text/plain' );
  199. is( $status, 415, 'a body of a media type the operation does not take is a 415' );
  200. is( [ $router->validate( $route, body => { count => 3 }, content_type => "$form_type; charset=UTF-8" ) ], [], 'and parameters of the media type do not count' );
  201. };
  202. subtest 'the names of a body' => sub {
  203. my $form_type = 'application/x-www-form-urlencoded';
  204. my $multipart = 'multipart/form-data; boundary=x';
  205. my ( $status, @errors ) = $router->validate( $router->match( 'GET', '/loose' ), body => { count => 3 }, content_type => $form_type );
  206. is( $status, 400, 'a field, to an operation with no requestBody, is a 400' );
  207. like( \@errors, [qr/\/body\/count:[ ]the[ ]operation[ ]takes[ ]no[ ]body/], 'for that reason' );
  208. ( $status, @errors ) = $router->validate( $router->match( 'GET', '/loose' ), body => {}, uploads => ['file'], content_type => $multipart );
  209. is( $status, 400, 'and so is a file' );
  210. like( \@errors, [qr/\/body\/file:/], 'named' );
  211. is( [ $router->validate( $router->match( 'GET', '/loose' ), body => {}, content_type => $form_type ) ], [], 'but an empty body is not' );
  212. my $route = $router->match( 'POST', '/upload' );
  213. is( [ $router->validate( $route, body => { count => 3 }, uploads => ['file'], content_type => $multipart ) ], [], 'a field and a file that the schema names pass' );
  214. ( $status, @errors ) = $router->validate( $route, body => { count => 3, junk => 1 }, content_type => $multipart );
  215. is( $status, 400, 'a field that a schema without additionalProperties does not name is a 400' );
  216. like( \@errors, [qr/\/body\/junk:[ ]the[ ]schema[ ]of[ ]multipart\/form-data/], 'which names the media type' );
  217. ( $status, @errors ) = $router->validate( $route, body => { count => 3 }, uploads => ['other'], content_type => $multipart );
  218. is( $status, 400, 'and so is a file that it does not name' );
  219. like( \@errors, [qr/\/body\/other:/], 'named' );
  220. ( $status, @errors ) = $router->validate( $route, body => { count => 3, file => 'x' }, content_type => $form_type );
  221. is( $status, 400, 'the names are those of the media type that was sent, here a $ref' );
  222. like( \@errors, [qr/\/body\/file:[ ]the[ ]schema[ ]of[ ]\Q$form_type\E/], 'which does not name the file' );
  223. is( [ $router->validate( $route, body => { count => 3 }, content_type => $form_type ) ], [], 'and does name the count' );
  224. 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' );
  225. };
  226. subtest 'precedence' => sub {
  227. my @order = map { $_->{template} } grep { index( $_->{template}, '/sitemap/' ) == 0 || index( $_->{template}, '/lib/' ) == 0 } $router->routes();
  228. is( \@order, [qw{/lib/index /sitemap/static.xml /sitemap/{map}.xml.gz /sitemap/{map}.xml /lib/{module+}}], 'literal routes first, then by segment, then by literal length' );
  229. };
  230. done_testing();