0

Imagine entities Genre and Book.

Each have API resource endpoints /genre and /book. In Laravel routes that might be:

 $app->resource('/genre', GenreController::class);

I want an endpoint for the relationship. GET /genre/1/book, to get books under the Genre #1.

What is best practice here? Place the handlers in GenreController, BookController or maybe a whole new controller?

On a sidenote, I am using the dingo-api package, but I don't suppose that makes any difference.

Zoon
  • 1,068
  • 2
  • 11
  • 26
  • Is the output from your `genre.books` going to be the same as `books` but just having it scoped to `genre` or will you be including additional information. Also, will you be wanting to create a whole resource from `genre.books` or will it just be the `index` and `show` methods? – Rwd Mar 04 '17 at 11:49
  • Also, what version of Laravel are you using? – Rwd Mar 04 '17 at 11:51
  • @RossWilson I am using Lumen/Laravel 5.4. I expect I want to return the same result, and I like the idea of having the `store` and `update` methods for consistency, but I don't want to end up with too much unnecessary complexity. – Zoon Mar 04 '17 at 11:53
  • Cool, cool. And is it just the `index` and `show` methods you want for the routes? – Rwd Mar 04 '17 at 11:57
  • @RossWilson I like the idea of having the `store` and `update` methods for consistency, but I don't want to end up with too much unnecessary complexity. – Zoon Mar 04 '17 at 11:58

3 Answers3

2

One option would be to just use your current BooksController. Add the below to your BooksController:

public function __construct(Request $request)
{
    if ($genreId = $request->route('genre')) {

        $request->route()->forgetParameter('genre');

        Book::addGlobalScope('genreScope', function ($query) use ($genreId) {
            $query->whereGenreId($genreId);
        });
    }
}

This will allow your books to be scope by the Genre and also remove it as a route param.

Then you route would just be:

$api->resource('genre.book');

Please note that with this method you would still use your store and update methods in the same way i.e. pass the genre_id in the request.

Hope this helps!

Rwd
  • 34,180
  • 6
  • 64
  • 78
  • Thanks, I like the reusability! `$request->route($param = null)` is new to me, can you point me in the right direction, as for how/why `route('genre')` is the genre ID? – Zoon Mar 04 '17 at 13:56
  • @Zoon When you do `Route::resource()` (or `$app` in your case) it will basically generate all the routes for a resource for you. Take `show`, resource will create the route that is basically `get('genre/{genre}/book/{book}')`. `{genre}` is basically saying that uri segment is variable and can be accessed with the index "genre" hence `$request->route('genre')` – Rwd Mar 04 '17 at 14:20
  • @Zoon Did this solve your problem? If so, would you mark it as correct. – Rwd Mar 06 '17 at 00:10
  • I am still testing this and looking into ways of authorizing the route parameter. Fx (in this example) a `genre` might be restricted to some users, but I am not sure if `$this->user` will be available on the constructor. Also, the route functionality in your example will *not* be available in the Lumen version of Laravel. – Zoon Mar 06 '17 at 23:48
  • @Zoon I wouldn't try to put auth logic in the above. I would suggest using something like Policies for that https://laravel.com/docs/5.4/authorization. If you do need further help with it please open a new question as it will go outside the scope of this one :) – Rwd Mar 08 '17 at 08:51
1

Although there is no 100% concrete answer here - its nearly always easier to have a controller per resource, and then one for the relationship if you want to tap it directly, which by the sounds of it, is what you want to do.

If you can (generally) stick to the main actions (index, create, store, show, edit, update, delete) down the line, it will make it easier. It will keep things organised, and future developers working on your project will be easily able to follow the structure.

Great reading: DHH approach for basecamp: http://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/

Whitehouse API guide: https://github.com/WhiteHouse/api-standards#white-house-web-api-standards

Chris
  • 54,599
  • 30
  • 149
  • 186
0

So @Chris suggested a dedicated controller for the relationship, and @RossWilson has a genius way to re-use a controller for the relationship (at least for actions that load Book).

Unfortunately Lumen's RouteProvider returns a simple array, and as such does not have the convenience of $request->route($param) and more importantly, $request->route()->forgetParameter($param).

=== New solution ===

I ended up doing basically exactly the same as @RossWilson suggested, just in a way that was supported by Lumen. Rather than getting the Route parameter in the Controller's __construct, I made a middleware that moved the Route parameter onto both the Request's input and query arrays.

The Middleware looks something like this:

public function handle($request, $next)
{
    if ($genre_id = Arr::get($request->route()[2], 'genre')) {
        // Add 'genre_id' to the input array (not replacing it if it already exists).
        $request->merge(['genre_id' => $request->input('genre_id', $genre_id)]);
        // Add 'genre_id' to the query array.
        $request->query->add('genre_id', $genre_id);

        // Forget the route parameter
        // Has to be done manually, because Lumen...
        $route = $request->route();
        $request->setRouteProvider(function() use ($route) {
            Arr::forget($route[2], 'genre');
            return $route;
        });
    }

    // Pass the updated $request to $next.
    return $next($request);
}

In my implementation I only set the query parameters for GET and DELETE requests, and input parameters for POST and PUT.

Then you can re-use the BookController for the genre.book resource, filtering from $request->query('genre_id') and associating the relationship from $request->input('genre_id').

=== Original solution ===

Instead I ended up with a with a dedicated relationship controller GenreBookController, that inherits from the non-relationship controller BookController. It's not as elegant as it could be, because of the method declarations that need to match (see how $book_id = null below to work-around this), but it is quite slim and dry.

GenreBookController extends BookController:

protected function addGlobalScope($genre_id)
{
    // Thanks Ross Wilson for the global scope suggestion.
    Book::addGlobalScope('genreScope', function ($query) use ($genre_id) {
        $query->where('genre_id', $genre_id);
    });
}

public function show(Request $request, $genre_id, $book_id = null)
{
    $this->addGlobalScope($genre_id);
    return parent::show($request, $book_id);
}

and for BookController, the show method is business as usual (it doesn't need to know about the extra parameter on show).

I also came up with a simple way for GenreBookController to pass through the genre parameter to the store method:

public function store(Request $request, $genre_id)
{
    // This way lets an input genre_id override the Route parameter.
    $request->merge(['genre_id' => $request->input('genre_id', $genre_id)]);

    // This way forces the Route parameter to be used over input parameters.
    $request->merge(['genre_id' => $genre_id]);

    return parent::store($request);
}

and again, for BookController it is business as usual, and it may of course make any validation/authorization for the genre passed through $request->input('genre_id'). That way there is no duplicated validation and authorization logic.

A note on FormRequests

If you are using FormRequests to validate the genre_id, the validation takes place before the GenreBookController can set the genre_id input variable from the route parameter.

As I see it you have two options:

  1. Use a middleware to move the route parameter onto the Request input.
  2. Put it on the authorize method of your FormRequest (I haven't tested this, as I don't like to put such logic here).

Laravel: If you are not using Lumen, I suggest taking a look at @RossWilson's answer, it is a little cleaner in my opinion.

Zoon
  • 1,068
  • 2
  • 11
  • 26