Laravel assert redirect with regular expression - php

I am newly trying out TDD with laravel, and I want to assert if a redirect took a user to a url that has an integer param.
I wonder if I could use regex to catch all positive integers.
I'm running this app with the laravel 5.8 framework and I know that the url parameter is 1 because I refresh the database each for each test, so setting the redirect url as /projects/1 works but this sort of hardcoding feels weird.
I've attached a block of code I tried using regex for but this doesn't work
/** #test */
public function a_user_can_create_projects()
{
// $this->withoutExceptionHandling();
//If i am logged in
$this->signIn(); // A helper fxn in the model
//If i hit the create url, i get a page there
$this->get('/projects/create')->assertStatus(200);
// Assumming the form is ready, if i get the form data
$attributes = [
'title' => $this->faker->sentence,
'description' => $this->faker->paragraph
];
//If we submit the form data, check that we get redirected to the projects path
//$this->post('/projects', $attributes)->assertRedirect('/projects/1');// Currently working
$this->post('/projects', $attributes)->assertRedirect('/^projects/\d+');
// check that the database has the data we just submitted
$this->assertDatabaseHas('projects', $attributes);
// Check that we the title of the project gets rendered on the projects page
$this->get('/projects')->assertSee($attributes['title']);
}
I expected the test to treat the argument in assertRedirect('/^projects/\d+'); as regex and then pass for any url like /projects/1 so far it ends in a number, but it takes it as a raw string and expects a url of /^projects/\d+
I'd appreciate any help.

After watching a tutorial by Jeffery Way, he talked about handling this issue.
Here's how he solves the situation
//If we submit the form data,
$response = $this->post('/projects', $attributes);
//Get the project we just created
$project = \App\Project::where($attributes)->first();
// Check that we get redirected to the project's path
$response->assertRedirect('/projects/'.$project->id);

This is not possible by now. You need to test the Location header in the response with a regular expression.
This is a problem because you cann't use the current route name. That's why I did two functions that bring a little bit of readability to your test. You will use this function like this:
// This will redirect to some route with an numeric ID in the URL.
$response = $this->post(route('groups.create'), [...]);
$this->assertResponseRedirectTo(
$response,
$this->prepareRoute('group.detail', '[0-9]+'),
);
This is the implementation.
/**
* Assert whether the response is redirecting to a given URI that match the pattern.
*/
public function assertResponseRedirectTo(Illuminate\Testing\TestResponse\TestResponse $response, string $url): void
{
$lastOne = $this->oldURL ?: $url;
$this->oldURL = null;
$newLocation = $response->headers->get('Location');
$this->assertEquals(
1,
preg_match($url, $newLocation),
sprintf('Should redirect to %s, but got: %s', $lastOne, $newLocation),
);
}
/**
* Build the pattern that match the given URL.
*
* #param mixed $params
*/
public function prepareRoute(string $name, $params): string
{
if (! is_array($params)) {
$params = [$params];
}
$prefix = 'lovephp';
$rep = sprintf('%s$&%s', $prefix, $prefix);
$valuesToReplace = [];
foreach ($params as $index => $param) {
$valuesToReplace[$index] = str_replace('$&', $index . '', $rep);
}
$url = preg_quote(route($name, $valuesToReplace), '/');
$this->oldURL = route($name, $params);
foreach ($params as $index => $param) {
$url = str_replace(
sprintf('%s%s%s', $prefix, $index, $prefix),
$param,
$url,
);
}
return sprintf('/%s/', $url);
}

Related

How to pass URL parameter into a PHP file?

everyone.
I have created very basic router in PHP and now I am stuck.
The user can navigate to different URLs and pass parameters that can be used to display data for example to get data from an array.
However I am stuck, I do not know how to pass these url parameters so they can be used inside a file.
For example this route
"/user/:id" -> If user navigates to /user/1 -> This executes a callback function and he receives data from an array.
However when the url doesn't have callback function but has a name of a file, the router will load a file, for example the user page.
Router::get("/user/:username", "user.php");
So my question is How can I get the "username" from the route and pass it into the user.php file ?
I have tried using $_GET['username'], however that doesn't work as the url doesn't have ? inside of it.
This is my code
<?php
class Router{
public static $routes = [];
public static function get($route, $callback){
self::$routes[] = [
'route' => $route,
'callback' => $callback,
'method' => 'GET'
];
}
public static function resolve(){
$path = $_SERVER['REQUEST_URI'];
$httpMethod = $_SERVER['REQUEST_METHOD'];
$methodMatch = false;
$routeMatch = false;
foreach(self::$routes as $route){
// convert urls like '/users/:uid/posts/:pid' to regular expression
$pattern = "#^" . preg_replace('/\\\:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', preg_quote($route['route'])) . "$#D";
$matches = Array();
// check if the current request matches the expression
if(preg_match($pattern, $path, $matches) && $httpMethod === $route['method']) {
$routeMatch = true;
// remove the first match
array_shift($matches);
// call the callback with the matched positions as params
if(is_callable($route['callback'])){
call_user_func_array($route['callback'], $matches);
}else{
self::render($route['callback']);
}
}
}
if(!$routeMatch){
self::notFound();
}
}
public static function render($file, $viewsFolder='./views/'){
include($viewsFolder . $file);
}
public static function notFound(){
http_response_code(400);
include('./views/404.php');
exit();
}
}
Router::get("/", "home.php");
Router::get("/user/:id", function($val1) {
$data = array(
"Nicole",
"Sarah",
"Jinx",
"Sarai"
);
echo $data[$val1] ?? "No data";
});
Router::get("/user/:username", "user.php");
Router::get("/user/profile/:id", "admin.php");
Router::resolve();
?>
You could pass $matches to the render() method as second optional parameter, and that's it. As well as these variables are accessible in the method scope, they are accessible in all the files included/required from this scope. I.e.:
self::render($route['callback'], $matches);
and in the included file:
print_r($matches);
UPD: In order to IDE not highlighting "unknown" variable, you can add a phpdoc-block somewhere in the included file, like this:
/** #var array $matches */

Laravel 8 routes to controllers. SEO friendly URL structure

I am trying to figure out how to achieve a specific URL structure in a Laravel 8 project and the necessary route to achieve this. What I want is:
// Example urls to listings in the business directory.
// These urls should be routed to the directory controller.
www.domain-name.com/example-business-name-d1.html
www.domain-name.com/example-business-name-d15.html
www.domain-name.com/example-business-name-d100.html
www.domain-name.com/example-business-name-d123.html
www.domain-name.com/example-business-name-d432.html
// Example urls to articles/posts in the blog.
// These urls should be routed to the posts controller.
www.domain-name.com/example-post-name-p5.html
www.domain-name.com/example-post-name-p11.html
www.domain-name.com/example-post-name-p120.html
www.domain-name.com/example-post-name-p290.html
www.domain-name.com/example-post-name-p747.html
// We want to avoid the more traditional:
www.domain-name.com/directory/example-business-name-1.html
www.domain-name.com/blog/example-post-name-5.html
This is because we don't want the strings “directory” or “blog” contained in the url for every listing or blog post. Search engine results work better without it.
So far I am using a catch-all route {any} at the bottom of the web.php routes file to “catch all” routes that get that far. I then manipulate the string provided by the path to get the ID and single character token from the end of the urls. I then have these 2 variables but can figure out how to pass these onto the right controllers!
Or am I being really dumb and there is a much better way of achieving this?
Route::get('{any}', function($any = null){
// Break up the url into seperate parts.
$pieces = explode("-", $any);
$pieces = array_reverse($pieces);
$piece = $pieces[0];
// Remove the .html
$piece = substr($piece, 0, -5);
// Get the two parts of the identifier.
$id = substr($piece, 1);
$token = substr($piece, 0, 1);
// Call correct controller based on the token.
switch ($token) {
case "d":
// HERE I WANT TO PASS THE ID ON TO THE SHOW ACTION OF THE DIRECTORY CONTROLLER
break;
case "p":
// HERE I WANT TO PASS THE ID ON TO THE SHOW ACTION OF THE POSTS CONTROLLER
break;
default:
return abort(404);
break;
}
});
I would split the path into 2 variables ($slug and $id) and directly pass it to the controller.
Route::get('{slug}-d{id}.html', 'DirectoryController#show')
->where(['slug' => '([a-z\-]+)', 'id' => '(\d+)']);
Route::get('{slug}-p{id}.html', 'PostController#show')
->where(['slug' => '([a-z\-]+)', 'id' => '(\d+)']);
And in your controllers
class DirectoryController
{
public function show(string $slug, int $id) {}
}
class PostController
{
public function show(string $slug, int $id) {}
}
I can see two ways of achieving this result:
Create an intermediate controller
Route::get('{path}', 'CheckPathController#redirect')
Then in your CheckPathController you do all the checks and your call the proper controller action:
public function redirect(Request $request, $path) {
// Your checks on $path, extract $id and content type
if($isPost) {
$controller = resolve(PostController::class);
return $controller->show($request, $id);
}
if($isBusiness) {
$controller = resolve(BusinessController::class);
return $controller->show($request, $id);
}
// No matches, error 404
abort(404);
}
Complex regex
see: https://laravel.com/docs/8.x/routing#parameters-regular-expression-constraints
I'm not a regexp master, this should be a basic was to match any {word}-{word}-...-p{id}.html pattern but it will break in case of unexpected chars
Route::get('{path}', 'PostController::show')
->where(['path' => '([\w]*-)*p[0-9]+\.html$']);
Route::get('{path}', 'BusinessController::show')
->where(['path' => '([\w]*-)*d[0-9]+\.html$']);
Note that in this case, you controller will receive the pull $path string, so you will need to extract the id there.
You can match the slug using regex
Route::get('/{any}', 'YourController#methodName')->where(['any' => '.*(-d(.*?)\.).*']);
Repeated with p
Then when you pickup your $site in your controller method you can use regex to grab the site.
public function methodName($site)
{
preg_match('/.*(-(d(.*?))\.).*/', $site, $parts); //or something similar, $parts[2] will have what you want
}
OR
This will give your controller method d{number} or p{number}
Route::get('/{site}', function($site) {
$code = preg_match('/.*(-(d(.*?)|p(.*?))\.).*/', $site, $parts) ? $parts[2] : null;
$controllerName = 'ControllerA';
if(isset($code) && !is_null($code) && Str::contains($code, 'p')) {
$controllerName = 'ControllerB';
}
$controller = app()->make('App\Http\Controllers\Application\\' . $controllerName);
return $controller->callAction('methodName', $params = ['code' => $code]);
})->where(['site' => '.*(-(d|p)(.*?)\.).*']);

Laravel Assert Redirect Ignoring Query Parameters

I have an api test that asserts for redirect to a specific route. The issue is the redirect url has query parameters that includes a timestamp which changes. Wondering if Laravel's assertions has an an alternative to assertRedirect method that ignores query parameters.
/** #test */
public function test_can_redirect()
{
$this->call('GET', "users/auth")
->assertRedirect('http://localhost:8000/dashboard?timestamp=1550848436');
}
I want to assert a redirect to
http://localhost:8000/dashboard
not
http://localhost:8000/dashboard?timestamp=1550848436
You could use getTargetUrl to extract what is without query string.
public function test_can_redirect()
{
$redirectUrl = 'http://localhost:8000/dashboard';
$res = $this->getJson('users/auth')->assertStatus(302); # Check if redirection
# Or you could use this: $this->assertTrue($res->isRedirection());
$parts = explode('?', $res->getTargetUrl());
$this->assertTrue($parts[0] === $redirectUrl); # parts[0] is the url
wihtout query string
# If you want to check the keys of query string, too
$this->assertTrue(count($parts) === 2); # check query string exists
parse_str($parts[1], $query);
$this->assertArrayKeys($query, ['time', ..., 'the key you want to check']);
}
protected function assertArrayKeys(array $array, array $checks)
{
$keys = array_keys($array);
foreach ($checks as $check) {
$this->assertTrue(in_array($check, $keys));
}
}
The principle behind testing your code is to make sure that what is happening is what is expected, so if you are expecting the query parameter of a timestamp, why not build it up too?
/** #test */
public function test_can_redirect()
{
$url = "/dashboard";
// http://php.net/manual/en/function.time.php
$timestamp = time();
$this->call('GET', "users/auth")
->assertRedirect($url . '?timestamp=' . $time);
}

preg_replace_callback not returning the correct data

This is in relation to a previous question I asked before: Replacing named 'parameters' within a string in PHP
That little class used to work, but it seems to be misbehaving, now that I'm attempting to move it over to the new Bolt CMS as an extension.
The intention is to grab data from a YML file. The data looks like so:
redirects:
contentToPage:
from: "content/(slug:any)"
to: "page/{slug}"
The extension loops through this data and compares it to the current Request URI, obtained from the applicable Symfony component. If there is a match, the user will be redirected accordingly. So, in this case, if a user tried to visit content/test, they would be redirected to page/test.
Something seems to be going wrong though, where the converted replacement isn't correct, or I get thrown an error. First, here's the block in question:
$convertedReplacements = preg_replace_callback("/^{$convertedPlaceholders}$/", function ($captures) {
$result = $this->destination;
for ($c = 1, $n = count($captures); $c < $n; ++$c) {
$value = array_shift($this->computedReplacements);
$result = str_replace("\{$value\}", $captures[$c], $result);
}
return $result;
}, $requestUri);
$convertedPlaceholders contains the replaced parameters in the from value. So, (slug:any) would be replaced with ([a-z0-9\.\-\_\%\=]+). Now, that works, but the function throws this exception: preg_replace_callback(): Unknown modifier '('.
However, if I change the regex delimiters from / to ~ or #, I don't get the error. Instead, I get the value of the to property in the YML file. In this case, I get page/{slug} and not page/test.
I must be doing something stupid, and I have no idea what it is. For all I know, there's just something that I left out that I can't see.
Here's the entire extension:
<?php
// Redirector Extension for Bolt
// Minimum version: 1.2
namespace Redirector;
use Silex\Application as Application;
use Symfony\Component\HttpFoundation\Request as Request;
use Symfony\Component\Yaml\Parser as Parser;
use Bolt\BaseExtension as BoltExtension;
class Extension extends BoltExtension
{
protected $placeholders = array(
':all' => '.*',
':alpha' => '[a-z]+',
':alphanum' => '[a-z0-9]+',
':any' => '[a-z0-9\.\-\_\%\=]+',
':num' => '[0-9]+',
':segment' => '[a-z0-9\-\_]+',
':segments' => '[a-z0-9\-\_\/]+'
);
protected $computedReplacements;
protected $destination;
/**
* Basic information about the extension. Shown in the Bolt Admin Environment.
*
* #return Array
*/
function info() {
$data = array(
'name' => 'Redirector',
'version' => '0.1',
'author' => 'Foundry Code - Mike Anthony',
'description' => 'An extension that allows you to perform any pre-app <code>301 Moved Permanently</code> redirects.',
'type' => 'Pre-app Hook',
'link' => 'http://code.foundrybusiness.co.za/extensions/bolt-redirector',
'first_releasedate' => '2013-08-28',
'latest_releasedate' => '2013-08-28',
'required_bolt_version' => '1.2',
'highest_bolt_version' => '1.2'
);
return $data;
}
/**
* Initialise the extension's functions
*
* #return void
*/
function initialize() {
$this->options = $this->config['options'];
$this->redirects = $this->config['redirects'];
$this->handleRedirects();
}
/**
* Check for a redirect. If it exists, then redirect to it's computed replacement.
*
* #return ? Response
*/
function handleRedirects()
{
$redirector = $this;
$this->app->before(function (Request $request) use ($redirector) {
if (empty($redirector->redirects)) {
return;
}
$requestUri = trim($request->getRequestUri(), '/');
$availablePlaceholders = '';
foreach ($this->placeholders as $placeholder => $expression) {
$availablePlaceholders .= ltrim("$placeholder|", ':');
}
$availablePlaceholders = rtrim($availablePlaceholders, '|');
//die($availablePlaceholders);
$pattern = '/\{(\w+):('.$availablePlaceholders.')\}/';
//die($pattern);
foreach ($this->redirects as $redirectName => $redirectData) {
$this->computedReplacements = array();
$this->destination = $redirectData['to'];
$from = rtrim($redirectData['from'], '/');
$to = rtrim($redirectData['to'], '/');
$convertedPlaceholders = preg_replace_callback($pattern, function ($captures) {
$this->computedReplacements[] = $captures[1];
return '(' . $this->placeholders[":{$captures[2]}"] . ')';
}, $from);
//die($convertedPlaceholders);
$convertedReplacements = preg_replace_callback("/^{$convertedPlaceholders}$/", function ($captures) {
$result = $this->destination;
for ($c = 1, $n = count($captures); $c < $n; ++$c) {
$value = array_shift($this->computedReplacements);
$result = str_replace("\{$value\}", $captures[$c], $result);
}
return $result;
}, $requestUri);
die($convertedReplacements);
if (preg_match("~^{$convertedPlaceholders}$~i", $requestUri)) {
return $this->app->redirect("/$convertedReplacements", 301);
}
}
}, Application::EARLY_EVENT);
}
}
Any ideas as to what I can do here?
If you want to use / as regex delimiter, you should escape all the / characters in the regex, or, better, use another delimiter as you did.
Right, it seems to be working now. For some reason, everything hopped into place when I changed:
str_replace("\{$value\}", $captures[$c], $result);
to
str_replace('{' . $value . '}', $captures[$c], $result);
I see it has something to do with escaping, which is weird. In the first one, I used double quotes, and escaped the curly brackets so that it would render as {slug}. But, for some reason, that did not happen. Any ideas as to why?

Using Regex to match url for route function. Can't get Regex to work

I am trying to build a router function to properly match incoming URI's and match them to an array of stored system URI's. I also have wildcards '(:any)' and '(:num)' similar to CodeIgniter.
Basically, I am trying to get the 'admin/stats/(:num)' entry to match on both 'admin/stats' and admin/stats/1'.
While the script is starting I grab all paths from a separate array and use a foreach to save each path:
route('admin/stats/(:num)', array('#title' => 'Statistics',...));
The function is:
function route($path = NULL, $options = NULL) {
static $routes;
//If no arguments are supplied, return all routes stored.
if(!isset($path) && !isset($options)) {
return $routes;
}
//return options for path if $path is set.
if(isset($path) && !isset($options)) {
//If we have an exact match, return it.
if(array_key_exists($path, $routes)) {
return $routes[$path];
}
//Else, we need to use RegEx to find the correct route options.
else {
$regex = str_replace('/', '\/', $path);
$regex = '#^' . $regex . '\/?$#';
//I am trying to get the array key for $route[$path], but it isn't working.
// route_replace('admin/stats/(:num)') = 'admin/stats/([0-9]+)'.
$uri_path = route_replace(key($routes[$path])); //route_replace replaces wildcards for regex.
if(preg_match($regex, $uri_path)) {
return $routes[$path];
}
}
}
$routes[$path] = $options;
return $routes;
}
Route replace function:
function route_replace($path) {
return str_replace(':any', '.+', str_replace(':num', '[0-9]+', $path));
}
A key/value pair in the $routes array looks like:
[admin/stats/(:num)] => Array
(
[#title] => Statistics //Page title
[#access] => user_access //function to check if user is authorized
[#content] => html_stats //function that returns HTML for the page
[#form_submit] => form_stats //Function to handle POST submits.
)
Thanks for the help. This is my first router and I am not that familiar in making proper Regex's.
'admin/stats/(:num)' will never match 'admin/stats' as in your "pattern" the slash is required. In pseduo-regex you need to do something like 'admin/stats(/:num)'.
There does also seem to be a few bugs in your code. This line
$uri_path = route_replace(key($routes[$path]));
is in the block that is executed when $path is not a key that exists in $routes.
I've tried to rewrite it and this seems to work (this is just the else clause):
foreach( array_keys( $routes ) as $route ) {
$regex = '#^' . $route . '?$#';
//I am trying to get the array key for $route'$path', but it isn't working.
// route_replace('admin/stats/(:num)') = 'admin/stats/('0-9'+)'.
$uri_path = route_replace($regex); //route_replace replaces wildcards for regex.
if(preg_match($uri_path,$path)) {
return $routes[$route];
}
}
But this requires 'admin/stats/(:num)' to be 'admin/stats(/:num)'.
btw if you don't have one already, you should get a debugger (Zend and xDebug are two of the most common ones for PHP). They can be invaluable in solving problems like this.
Also, ask yourself if you need to write a router, or whether you can't just use one of the perfectly good ones out there already...

Categories