Handling Controllers for API versioning


#1

I have found answers on how to handle API versioning rouites, simply by using groups like ‘v1’, ‘v2’, …, ‘vx’ etc. like explained there: Slim 3 API Versioning

But what is the proper way to handle Controllers or other version dependand code in Slim? Should i create all Controllers from scratch for new version of API in namespace of new API version?

For example i have structure like:

src/Controllers/api/v1/UsersController.php

When i release v2 is there any other way than creating new Controller for this version like:

src/Controllers/api/v2/UsersController.php

This way code becomes hard to maintain especially when bugfix applies somehow to all versions. What is the proper way you handle versioning of code for new API versions?


#2

I use Class:Method container resolution so its fairly trivial. This is what I’ve done with my projects with great success.

I have the following directory structure, each class is properly namespaced as well.

/controllers/api/v1/UserController.php
/controllers/api/v2/UserController.php

I also have a routes.php configuration file that looks something like this

$app->group('/api', function() {
    $app->group('/v1', function() {
        $this->get('/user/{id:\d+}/', 'Controller\Api\V1\UserController:get');
        $this->post('/user/', 'Controller\Api\V1\UserController:post');
    }
    $app->group('/v2', function() {
       $this->get('/user/{id:\d+}/', 'Controller\Api\V2\UserController:get');
    }
}

You could extend your original controller and change the implementation of old methods, or create new ones.

#/controllers/api/v2/UserController.php

namespace Controller\Api\V2

# use the previous version of this class
use Controller\Api\V1\UserController as UserControllerV1

# extend from previous version
class UserController extends UserControllerV1
{
    public function get($req, $res, $args)
    {
        # New implementation here
    }
}

With your new api endpoint you can add new methods, alter existing implementation while still inheriting all of V1’s methods. You can continue this inheritance chain to as many versions as you want.


#3

@eko3alpha: Thanks for your response to this post. I have some follow-up questions.

The Class:Method container resoltuion seems to indicate that I don’t need to create a factory in the container that instantiates the controller with the dependencies if the class does not have an entry int he container. In such a case, Slim will pass the container’s instance to the constructor. I can’t seem to get the code to work, though.

Here’s my directory structure.

<root dir>/src/controllers/v2/LoanController.php
<root dir>/includes/loan.php

I have this in the loan.php file.

<?php

	use \Psr\Http\Message\ServerRequestInterface as Request;
	use \Psr\Http\Message\ResponseInterface as Response;

	$app->group('/api', function () use ($app) {
		$app->group('/2.0', function() use ($app) {
			$app->get('/loan/{clientnumber}/{loannumber}', 'Controller\V2\LoanController:getClientLoan');
});
	});

?>

I have this in my LoanController.php file.

<?php

namespace Controller\V2;

Class LoanController
{
	protected $container;

	// constructor receives container instance
	public function __construct(ContainerInterface $container) {
		$this->container = $container;
	}

	public function getClientLoan(Request $request, Response $response, $args) {
}
	}

?>

Unfortunately, I am getting a Slim Application Error of “Callable Controller\V2\LoanController does not exist.” I think I am missing something small, but I just can’t figure it out. Any help is greatly appreciated.

Thanks!


#4

I’m assuming that you are using composer, what does your composer.json file look like? Did you dump autoload?


#5

@eko3alpha: Here is what I have in my composer.json file.

{
    "require": {
        "slim/slim": "^3.0",
        "monolog/monolog": "^1.18"
    }
}

I’m not familiar with “dump autoload.” Can you tell me more about it?


#6

From the command line
composer dump-autoload -o


#7

You will need to define PSR4 namespace mapping so the PHP autoloader can locate your custom classes. If you look inside your vendor/composer directory you will notice a bunch of autoload_* files. These are created automagically for you when you install/update/dump your composer dependencies. This is where and how your app is able to locate all the packages via name spacing, instead of manually calling “require/include” on every file.

Based on your class locations your PSR4 mapping should look something like this:

{
    "require": {
        "slim/slim": "^3.0",
        "monolog/monolog": "^1.18"
    },
    "autoload": {
        "psr-4": {
            "Controller\\": "/src/controllers/",
        }
    }
}

then make sure to dump autoload so composer can generate new class mappings and your application will be able to find your new classes.

Here are some more resources for you to look into:
PSR4
SPL Autoloading <—what composer defines for you
Brief PSR4 intro


#8

@tflight and @eko3alpha: Thanks for your responses! I have made those changes, but I am still getting the same message. Does composer produce some files that I can look at? Is there something else I am missing?


#9

in vendor/composer/ you should see autoload_classmap.php as one example.


#10

I noticed a discrepancy in your first post outlining your structure as

src/Controllers/api/v1/

vs later as the following

/src/controllers/v2/

Make sure you have the correct path and that the namespace matches the directory path.

your mapping should look like:

"Controller\\V2\\": "/src/controllers/v2",

or

"Controller\\V2\\": "/src/controllers/api/v2",

You’re using custom namespace mappings so check out the following file

/vendor/composer/autoload_psr4.php

You should see something that looks like the following, this is what mine looks like after I run “composer dump-autoload”

return array(
    'Whoops\\' => array($vendorDir . '/filp/whoops/src/Whoops'),
    'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
    'Symfony\\Component\\VarDumper\\' => array($vendorDir . '/symfony/var-dumper'),
    'Slim\\' => array($vendorDir . '/slim/slim/Slim'),
    'Service\\' => array($baseDir . '/app/services'),
    'Psr\\Log\\' => array($vendorDir . '/psr/log/Psr/Log'),
    'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-message/src'),
    'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
    'Monolog\\' => array($vendorDir . '/monolog/monolog/src/Monolog'),
    'Model\\Store\\' => array($baseDir . '/app/models/stores'),
    'Model\\Repository\\' => array($baseDir . '/app/models/repositories'),
    'Model\\Entity\\' => array($baseDir . '/app/models/entities'),
    'Middleware\\' => array($baseDir . '/app/middleware'),
    'Library\\' => array($baseDir . '/app/libraries'),
    'Interop\\Container\\' => array($vendorDir . '/container-interop/container-interop/src/Interop/Container'),
    'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'),
    'GuzzleHttp\\Promise\\' => array($vendorDir . '/guzzlehttp/promises/src'),
    'GuzzleHttp\\' => array($vendorDir . '/guzzlehttp/guzzle/src'),
    'Firebase\\JWT\\' => array($vendorDir . '/firebase/php-jwt/src'),
    'FastRoute\\' => array($vendorDir . '/nikic/fast-route/src'),
    'Core\\' => array($baseDir . '/app/core'),
    'Controller\\Api\\V1\\' => array($baseDir . '/app/controllers/api/v1'),
    'Controller\\' => array($baseDir . '/app/controllers'),
);

You should see your mapping. It should look something like

    'Controller\\Api\\V2\\' => array($baseDir . '/src/controllers/v2'),

The error you are getting is telling you your mapping is wrong and it cant find your class. Double check your directory path, namespace and make sure you “dump auto-load” after editing your composer.json


#11

@tflightand and @eko3alpha: You guys rock! Both of your comments helped.

@eko3alpha: You are correct that I had a discrepancy. That fixed my issue with the error.

@tflight and @eko3alpha: Looking at the files you mentioned, I can see my controller listed correctly in each.

So, my next question is this: how does the controller work with the container? I’m new to all of this, so if you want to answer me by directing me to a tutorial, I will happily take that answer. :slight_smile: I read through Routing section of the User Guide on the Slim Framework site, but I’m still not understanding it.

To give a practical example: my index.php creates a configuration array where it stores database connection information. The same file adds that configuration array to a \Slim\App object. How do I now access this information in the controller? Do I access it through the container? (Obviously, if the way in which I am doing it is not the best practice, please feel free to say that and correct me.)


#12

I think I finally figured this out.

Within my LoanController class, I added:

protected $container;

public function __construct($container) {
    $this->container = $container;
}

In the loan.php file I put this after the use lines and before the $app->group lines.

$container['LoanController'] = function($c) {
	return new \Controller\V2\LoanController($c);
};

I can now see the data in the $container using $this->container in any function in my class.

Thoughts?


#13

You can do it that way, however that isn’t considered best practice as you are using your container as a service locator. The best practice would be to use dependency injection and give your controller the pieces it needs in the container rather than the controller asking for things out of the container.

See Accessing Services in Slim 3 and DI Factories for Slim Controllers for more discussion on the topic. For bonus reading there is also the Add Dependencies section of the First Application Walkthrough as well as Registering a Controller with the Container, both in the Slim docs.


#14

@tflight: Thanks! Although I got it working, I would much rather use best practices. I will work through all your suggestions and see if I can get it to work using dependency injection. You will likely hear from me here either way. :slight_smile:


#15

There are several ways you could do it. I would recommend that you don’t inject the container. Instead create an abstract BaseController for your common dependencies, or create a service and pass your dependencies to the constructor. I’ll show you both ways. This gets very opinionated depending on who you ask…

Also check out this Slim Guide on one of the many ways to setup a Slim project.

The route can be the same for both methods

$app->get('/tags/', 'Controller\Tag\TagController:doSomething')->setName('tags');

Here is an example of extending a controller, this is good if you have a ton of controllers within a group that need the same dependencies.

/controllers/tags/BaseClass.php

namespace Controller\Tag;

abstract class BaseTagController
{
    protected $input;
    protected $tagRepo;
    protected $view;
    protected $logger;

    public function __construct($c)
    {
        $this->input       = $c['Input'];
        $this->tagRepo     = $c['TagRepository'];
        $this->view        = $c['View'];
        $this->logger      = $c['Logger'];
    }
}

/controllers/tags/TagController.php

namespace Controller\Tag;

class TagController extends BaseTagController
{
    public function doSomething(Request $request, Response $response, array $args)
    {

        $tags = $this->tagRepo->getById($this->input->get('id'));
        return $this->view->render('tags/listing', $tags);
    }
}

This is an example of using the container as a controller.

###/app/services/tag_controllers.php
$c[‘Controller\Tag\TagController’] = function($c){
return new Controller\Tag\TagController(
$c[‘Input’],
$c[‘View’],
$c[‘TagRepository’],
$c[‘Logger’]
);
}

###/app/controllers/tag/TagController.php
namespace Controller\Tag;

class TagController
{
    protected $input;
    protected $tagRepo;
    protected $view;
    protected $logger;

    public function __construct($input, $view, $tagRepo, $logger)
    {
        $this->input    = $input
        $this->view     = $view;
        $this->tagRepo  = $tagRepo;
        $this->logger   = $logger;
    }

    public function doSomething(Request $request, Response $response, array $args)
    {
        $tags = $this->tagRepo->getById($this->input->get('id'));
        return $this->view->render('tags/listing', $tags);
    }
}