Skip to content

Commit 76a8ce7

Browse files
committed
Merge pull request #188 from zf-fr/best-practices
Start writing best practices
2 parents 1305dae + 1988a87 commit 76a8ce7

File tree

3 files changed

+347
-2
lines changed

3 files changed

+347
-2
lines changed

docs/04. Using HTTP exceptions for reporting errors.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,6 @@ return [
7575

7676
### Navigation
7777

78-
* Back to [the Introduction](/docs/03. View layer.md)
78+
* Continue to [**Best practices**](/docs/05. Best practices.md)
79+
* Back to [the View Layer](/docs/03. View layer.md)
7980
* Back to [the Index](/docs/README.md)

docs/05. Best practices.md

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
# Best Practices
2+
3+
This section will show you various best practices that I've learn while writing several REST API, using ZfrRest.
4+
5+
## Be pragmatic
6+
7+
I know a lot of people will disagree with that, but you should be pragmatic when using your API. HATEOAS is nice,
8+
but it's actually not really needed (whenever I use an API, I never ever used the URL attributes of such APIs,
9+
because the workflow of which method to call is usually known in advance).
10+
11+
If being 100% REST compliant takes too much time or is not useful for your use case, don't lose time on that and
12+
go on.
13+
14+
[This link](http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api) contains a lot of best practices
15+
when designing an API.
16+
17+
## Use nested URIs
18+
19+
ZfrRest allows you to nest URLs, for accessing the same resource based on different contexts. For instance, if you
20+
have tweets, you can either access all tweets (`/tweets`) but also access all tweets from a given user (`/users/1/tweets`).
21+
22+
With the template feature of ZfrRest, you do not have to write rendering code multiple times. Instead, you will use
23+
the exact same template for the two cases, except with different set of data.
24+
25+
## List and resource controllers
26+
27+
You have to understand that you will typically have two controllers per resource: the list controller and the
28+
item controller. You should have a coherent naming that you use throughout your application (for instance you
29+
could add the `List` word for list controllers: `TweetController` and `TweetListController`).
30+
31+
This table will recap you the type of controller based on URI and verb:
32+
33+
URI | Verb | Type
34+
------------ | ------------- | ------------
35+
/tweets | POST | List controller
36+
/tweets | GET | List controller
37+
/tweets | PUT | N/A
38+
/tweets | DELETE | N/A
39+
/tweets/1 | POST | N/A
40+
/tweets/1 | GET | Item controller
41+
/tweets/1 | PUT | Item controller
42+
/tweets/1 | DELETE | Item controller
43+
44+
## Simplify your controllers
45+
46+
Controllers are known to be hard to test (you have to mock requests...). Instead, all your controllers must be kept
47+
very simple. To do that, you should use a service layer. In ZfrRest, here is how your controller typically look like.
48+
49+
* List controller
50+
51+
```php
52+
class TweetListController extends AbstractRestfulController
53+
{
54+
private $tweetService;
55+
56+
public function __construct(TweetService $tweetService)
57+
{
58+
$this->tweetService = $tweetService;
59+
}
60+
61+
/**
62+
* Create a new tweet
63+
*/
64+
public function post()
65+
{
66+
$data = $this->validateIncomingData(TweetInputFilter::class);
67+
$tweet = $this->hydrateObject(TweetHydrator::class, new Tweet(), $data);
68+
69+
$tweet = $this->tweetService->create($tweet);
70+
71+
return new ResourceViewModel(['tweet' => $tweet], ['template' => 'tweets/tweet']);
72+
}
73+
74+
/**
75+
* Get all the tweets
76+
*/
77+
public function get()
78+
{
79+
// Assuming getAll return a paginator
80+
$tweets = $this->tweetService->getAll();
81+
$tweets->setCurrentPageNumber($this->params()->fromQuery('page', 1);
82+
83+
return new ResourceViewModel(['tweets' => $tweets], ['template' => 'tweets']);
84+
}
85+
}
86+
```
87+
88+
* Item controller
89+
90+
```php
91+
class TweetController extends AbstractRestfulController
92+
{
93+
private $tweetService;
94+
95+
public function __construct(TweetService $tweetService)
96+
{
97+
$this->tweetService = $tweetService;
98+
}
99+
100+
/**
101+
* Get an existing tweet
102+
*/
103+
public function get(array $params)
104+
{
105+
if (!$tweet = $this->tweetService->getById($params['tweet_id'])) {
106+
throw new NotFoundException();
107+
}
108+
109+
return new ResourceViewModel(['tweet' => $tweet], ['template' => 'tweets/tweet']);
110+
}
111+
112+
/**
113+
* Update an existing tweet
114+
*/
115+
public function put(array $params)
116+
{
117+
if (!$tweet = $this->tweetService->getById($params['tweet_id'])) {
118+
throw new NotFoundException();
119+
}
120+
121+
$data = $this->validateIncomingData(TweetInputFilter::class);
122+
$tweet = $this->hydrateObject(TweetHydrator::class, $tweet, $data);
123+
124+
$tweet = $this->tweetService->update($tweet);
125+
126+
return new ResourceViewModel(['tweet' => $tweet], ['template' => 'tweets/tweet']);
127+
}
128+
129+
/**
130+
* Delete an existing tweet
131+
*/
132+
public function delete(array $params)
133+
{
134+
if (!$tweet = $this->tweetService->getById($params['tweet_id'])) {
135+
throw new NotFoundException();
136+
}
137+
138+
$this->tweetService->delete($tweet);
139+
140+
return new JsonModel();
141+
}
142+
}
143+
```
144+
145+
## Paginate data
146+
147+
To paginate data, you need to create an instance of `Zend\Paginator\Paginator`. DoctrineModule and DoctrineORMModule
148+
offers two adapters for Doctrine, that you can use depending on the use case: `DoctrineModule\Paginator\Adapter\Selectable`
149+
and `DoctrineORMModule\Paginator\Adapter\DoctrinePaginator`.
150+
151+
We recommend you to create the paginator in your repositories (or your services). Two cases can happen:
152+
153+
1. If you do not need a custom query (for instance if you only want to fetch the tweets, without any join or whatever),
154+
you can use the `Selectable` adapter, because repositories implement the `Selectable` interface. For instance, in your
155+
service:
156+
157+
```php
158+
use DoctrineModule\Paginator\Adapter\Selectable as SelectableAdapter;
159+
use Zend\Paginator\Paginator;
160+
161+
class TweetService
162+
{
163+
private $tweetRepository;
164+
165+
public function getAll()
166+
{
167+
return new Paginator(new SelectableAdapter($this->tweetRepository));
168+
}
169+
}
170+
```
171+
172+
> You can optionally pass a criteria object for filtering, as we'll see later.
173+
174+
2. If you need custom query, this approach is not flexible enough. We recommend you in this case to create a
175+
custom repository, that will create the query, and wrap it under a paginator:
176+
177+
First, your service now delegate to the repository:
178+
179+
```php
180+
class TweetService
181+
{
182+
private $tweetRepository;
183+
184+
public function getAll()
185+
{
186+
return new $this->tweetRepository->findAll();
187+
}
188+
}
189+
```
190+
191+
While your TweetRepository create the custom query, and wrap it around a Paginator:
192+
193+
```php
194+
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
195+
use DoctrineORMModule\Paginator\Adapter\DoctrinePaginator as DoctrinePaginatorAdapter;
196+
use Zend\Paginator\Paginator;
197+
198+
class TweetRepository extends EntityRepository
199+
{
200+
public function findAll()
201+
{
202+
$queryBuilder = $this->createQueryBuilder('tweet');
203+
$queryBuilder->select('author')
204+
->join('tweet.author', 'author');
205+
206+
$doctrinePaginator = new DoctrinePaginator($queryBuilder);
207+
return new Paginator(new DoctrinePaginatorAdapter($doctrinePaginator));
208+
}
209+
}
210+
```
211+
212+
## Filtering data
213+
214+
When retrieving data, you will often need to filter the result set through query parameters. One approach to do
215+
that is to extract the query params right into your controllers, and passing each filters to your service, which
216+
in turn will do the right query.
217+
218+
However, we definitely do not want to tie your controller to query params parsing. Once again, two cases can happen:
219+
220+
* You have simple filtering needs, when you only need to filter the fields of the entity you are fetching (for instance,
221+
filtering by first name, last name or age when retrieving a list of users).
222+
* You have more complex filtering needs, when you can possibly also filter by properties of associations (for instance,
223+
you want to filter by author first name when retrieving a list of tweets).
224+
225+
### Simple filtering
226+
227+
For filtering fields on the same entity, you can use a Criteria object. The Criteria object is a Doctrine abstraction
228+
that allows to filtering collections (it also works on repository) efficiently. For instance, let's say you want
229+
to filter users by first name and last name. To do that, you allow `first_name` and `last_name` query params:
230+
231+
```php
232+
use Doctrine\Common\Collections\Criteria;
233+
234+
class UserListController extends AbstractRestfulController
235+
{
236+
private $userService;
237+
238+
public function get()
239+
{
240+
// Get all the query params
241+
$queryParams = $this->params()->fromQuery();
242+
243+
// Create the criteria object
244+
$criteria = new Criteria();
245+
$builder = $criteria->expr();
246+
247+
foreach ($queryParams as $key => $value) {
248+
switch ($key) {
249+
case 'first_name':
250+
$criteria->andWhere($builder->eq('firstName', $value));
251+
break;
252+
253+
case 'last_name':
254+
$criteria->andWhere($builder->eq('lastName', $value));
255+
break;
256+
}
257+
}
258+
259+
$users = $this->userService->getAllByCriteria($criteria);
260+
261+
return new ResourceViewModel(['users' => $users]);
262+
}
263+
}
264+
```
265+
266+
With your `getAllByCriteria` creating a paginator, but with the Criteria object to further filtering the data set:
267+
268+
```php
269+
use DoctrineModule\Paginator\Adapter\Selectable as SelectableAdapter;
270+
use Zend\Paginator\Paginator;
271+
272+
class UserService
273+
{
274+
private $userRepository;
275+
276+
public function getAllByCriteria(Criteria $criteria)
277+
{
278+
return new Paginator(new SelectableAdapter($this->tweetRepository, $criteria));
279+
}
280+
}
281+
```
282+
283+
> As you can see, we can easily combine filtering and pagination thanks to the power of the Criteria API!
284+
285+
However, there is a problem with this approach: our controller is now polluted with code that is difficult to
286+
test in isolation, and cannot be reused elsewhere. To solve this problem, we introduce a new kind of objects: the
287+
Criteria objects.
288+
289+
Those objects simply extend the base Criteria, but know how to build the criteria object. For instance, here is
290+
a UserCriteria object:
291+
292+
```php
293+
class UserCriteria extends Criteria
294+
{
295+
public function __construct(array $filters = [])
296+
{
297+
$builder = $this->expr();
298+
299+
foreach ($queryParams as $key => $value) {
300+
switch ($key) {
301+
case 'first_name':
302+
$this->andWhere($builder->eq('firstName', $value));
303+
break;
304+
305+
case 'last_name':
306+
$this->andWhere($builder->eq('lastName', $value));
307+
break;
308+
}
309+
}
310+
}
311+
}
312+
```
313+
314+
Your controller now become:
315+
316+
```php
317+
use Doctrine\Common\Collections\Criteria;
318+
319+
class UserListController extends AbstractRestfulController
320+
{
321+
private $userService;
322+
323+
public function get()
324+
{
325+
$criteria = new UserCriteria($this->params()->fromQuery(null, []));
326+
$users = $this->userService->getAllByCriteria($criteria);
327+
328+
return new ResourceViewModel(['users' => $users]);
329+
}
330+
}
331+
```
332+
333+
Much cleaner!
334+
335+
### Complex filtering
336+
337+
This works well when filtering on fields that belong to the entity. However, the Criteria API is a rather limited API,
338+
and does not allow things such as filtering on association using joins. We therefore need to resolve to a similar
339+
approach, but without using the Criteria API itself.
340+
341+
* Back to [**Using HTTP exceptions for reporting errors**](/docs/04. Using HTTP exceptions for reporting errors.md)
342+
* Back to [the Index](/docs/README.md)

docs/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ If you are looking for some information that is not listed in the documentation,
2020

2121
4. [Using HTTP exceptions for reporting errors](/docs/04. Using HTTP exceptions for reporting errors.md)
2222
1. [Built-in exceptions](/docs/04. Using HTTP exceptions for reporting errors.md#built-in-exceptions)
23-
2. [Mapping custom exceptions](/docs/04. Using HTTP exceptions for reporting errors.md#mapping-custom-exceptions)
23+
2. [Mapping custom exceptions](/docs/04. Using HTTP exceptions for reporting errors.md#mapping-custom-exceptions)
24+
25+
5. [Best Practices](/docs/05. Best practices.md)

0 commit comments

Comments
 (0)