Skip to content

Commit 666f06c

Browse files
committed
Added caching and cleaned up
1 parent 695d2b5 commit 666f06c

9 files changed

Lines changed: 214 additions & 140 deletions

File tree

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"type": "library",
66
"require": {
77
"ext-simplexml": "*",
8+
"psr/cache": "^2.0 || ^3.0",
9+
"symfony/cache": "^6.4 || ^7.4",
810
"symfony/console": "^6.4 || ^7.4",
911
"symfony/http-client": "^6.4 || ^7.4",
1012
"symfony/http-foundation": "^6.4 || ^7.4",

mago.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ ambiguous-function-call = { enabled = false }
3434
literal-named-argument = { enabled = false }
3535
halstead = { effort-threshold = 7000 }
3636

37+
# Our Model classes have a lot of constructor arguments.
38+
excessive-parameter-list = {
39+
constructor-threshold = 10
40+
}
41+
42+
# Our API client has many methods.
43+
too-many-methods = {
44+
threshold = 20
45+
}
46+
3747
[analyzer]
3848
plugins = []
3949
find-unused-definitions = true

src/Client/ApiClient.php

Lines changed: 153 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,25 @@
66

77
use ItkDev\F2ApiClient\Exception\ApiException;
88
use ItkDev\F2ApiClient\Exception\RuntimeException;
9+
use ItkDev\F2ApiClient\Model\AbstractItem;
910
use ItkDev\F2ApiClient\Model\Atom;
1011
use ItkDev\F2ApiClient\Model\CaseFile;
12+
use ItkDev\F2ApiClient\Model\Document;
1113
use ItkDev\F2ApiClient\Model\Matter;
14+
use Psr\Cache\CacheItemInterface;
15+
use Psr\Cache\CacheItemPoolInterface;
16+
use Symfony\Component\Cache\Adapter\ProxyAdapter;
1217
use Symfony\Component\HttpClient\HttpClient;
1318
use Symfony\Component\HttpFoundation\Request;
1419
use Symfony\Component\HttpFoundation\Response;
1520
use Symfony\Component\OptionsResolver\OptionsResolver;
21+
use Symfony\Contracts\Cache\CacheInterface;
1622
use Symfony\Contracts\HttpClient\HttpClientInterface;
1723
use Symfony\Contracts\HttpClient\ResponseInterface;
1824

25+
/**
26+
* @phpstan-type AccessToken array{access_token: string, token_type: string, expires_in: int, refresh_token: string}
27+
*/
1928
class ApiClient
2029
{
2130
private readonly array $options;
@@ -40,53 +49,42 @@ public function __construct(array $options)
4049
*/
4150
public function getServiceIndex(): array
4251
{
43-
$path = '/F2Rest/ServiceIndex';
44-
$response = $this->client()->request(Request::METHOD_GET, $path, [
45-
'headers' => [
46-
'Accept' => 'application/json',
47-
],
48-
]);
52+
$cache = $this->getCache();
53+
$cacheKey = sha1(__METHOD__);
54+
55+
return $cache->get($cacheKey, function (CacheItemInterface $item): array {
56+
$item->expiresAfter((int) $this->options['cache_item_lifetime']);
57+
58+
$path = '/F2Rest/ServiceIndex';
59+
$response = $this->client()->request(Request::METHOD_GET, $path, [
60+
'headers' => [
61+
'Accept' => 'application/json',
62+
],
63+
]);
4964

50-
return $response->toArray();
65+
return $response->toArray();
66+
});
5167
}
5268

5369
/**
54-
* @param array{q: string, count: int} $query
5570
* @return Atom[]
5671
*
5772
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
5873
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
5974
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
6075
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
6176
*/
62-
public function caseSearch(string $q, int $count = 10): array
77+
public function caseSearch(string $searchTerms, int $count = 10): array
6378
{
6479
$query = [
65-
'q' => $q,
80+
'searchTerms' => $searchTerms,
6681
'count' => $count,
6782
];
68-
$path = '/F2Rest/search/cases';
69-
// @todo /F2Rest/ServiceIndex.json reports
70-
//
71-
// "http://cbrain.com/casefile/rel/case-search": {
72-
// "href": "/F2Rest/search/cases",
73-
// "title": "Case file search"
74-
// },
75-
//
76-
// which refers to the actual search URL: /F2Rest/searches/cases
77-
$path = '/F2Rest/searches/cases';
78-
// $path = $this->getRequestUrl('http://cbrain.com/casefile/rel/case-search');
79-
$response = $this->request(Request::METHOD_GET, $path, [
80-
'query' => $query,
81-
]);
8283

83-
$items = [];
84-
$sxe = new \SimpleXMLElement($response->getContent());
85-
foreach ($sxe->entry as $entry) {
86-
$items[] = Atom::fromSimpleXMLElement($entry);
87-
}
84+
$url = $this->getSearchRequestUrl('http://cbrain.com/casefile/rel/case-search', $query);
85+
$response = $this->request(Request::METHOD_GET, $url);
8886

89-
return $items;
87+
return $this->createSearchResult($response);
9088
}
9189

9290
public function caseById(string $id): CaseFile
@@ -100,39 +98,20 @@ public function caseById(string $id): CaseFile
10098
throw new ApiException($response);
10199
}
102100

103-
return CaseFile::fromSimpleXMLElement(new \SimpleXMLElement($response->getContent()));
101+
return $this->createItemResult($response, CaseFile::class);
104102
}
105103

106104
public function matterSearch(string $searchTerms, int $count = 10): array
107105
{
108106
$query = [
109-
'searchTerms' => $searchTerms,
110-
'count' => $count,
107+
'searchTerms' => $searchTerms,
108+
'count' => $count,
111109
];
112-
// @todo /F2Rest/ServiceIndex.json reports
113-
//
114-
// "http://cbrain.com/casefile/rel/case-search": {
115-
// "href": "/F2Rest/search/cases",
116-
// "title": "Case file search"
117-
// },
118-
//
119-
// which refers to the actual search URL: /F2Rest/searches/cases
120-
$url = '/F2Rest/searches/matters';
121-
$url = '/F2Rest/searches/matters?q={searchTerms}&count={count}';
122-
$url = $this->replacePlaceholders($url, $query);
123-
// $url = $this->getRequestUrl('http://cbrain.com/casefile/rel/matter-search', [
124-
// ]);
125-
$response = $this->request(Request::METHOD_GET, $url, [
126-
'query' => $query,
127-
]);
128110

129-
$items = [];
130-
$sxe = new \SimpleXMLElement($response->getContent());
131-
foreach ($sxe->entry as $entry) {
132-
$items[] = Atom::fromSimpleXMLElement($entry);
133-
}
111+
$url = $this->getSearchRequestUrl('http://cbrain.com/casefile/rel/matter-search', $query);
112+
$response = $this->request(Request::METHOD_GET, $url);
134113

135-
return $items;
114+
return $this->createSearchResult($response);
136115
}
137116

138117
public function matterById(string $id): Matter
@@ -146,7 +125,7 @@ public function matterById(string $id): Matter
146125
throw new ApiException($response);
147126
}
148127

149-
return Matter::fromSimpleXMLElement(new \SimpleXMLElement($response->getContent()));
128+
return $this->createItemResult($response, Matter::class);
150129
}
151130

152131
public function matterByMatterNumber(string $matterNumber): Matter
@@ -160,38 +139,54 @@ public function matterByMatterNumber(string $matterNumber): Matter
160139
throw new ApiException($response);
161140
}
162141

163-
return Matter::fromSimpleXMLElement(new \SimpleXMLElement($response->getContent()));
142+
return $this->createItemResult($response, Matter::class);
164143
}
165144

145+
public function documentById(string $id): Document
146+
{
147+
$url = $this->getRequestUrl('http://cbrain.com/casefile/rel/document-by-id', [
148+
'id' => $id,
149+
]);
150+
$response = $this->request(Request::METHOD_GET, $url);
151+
152+
if (Response::HTTP_OK !== $response->getStatusCode()) {
153+
throw new ApiException($response);
154+
}
155+
156+
return $this->createItemResult($response, Document::class);
157+
}
166158

167159
/**
168-
* @return array{access_token: string, token_type: string}
160+
* @return AccessToken
169161
*/
170162
protected function getAccessToken(): array
171163
{
172-
// @todo Check existing access token is not expired.
173-
174-
$client = $this->client();
175-
$response = $client->request(Request::METHOD_POST, '/F2Rest/oauth2/token', [
176-
'auth_basic' => [
177-
$this->options['api_username'],
178-
$this->options['api_secret'],
179-
],
180-
'headers' => [
181-
'accept' => 'application/json',
182-
],
183-
'body' => [
184-
'grant_type' => 'password',
185-
'username' => $this->options['f2_username'],
186-
],
187-
]);
164+
$cache = $this->getCache();
165+
$cacheKey = sha1(__METHOD__);
166+
167+
return $cache->get($cacheKey, function (CacheItemInterface $item): array {
168+
$client = $this->client();
169+
$response = $client->request(Request::METHOD_POST, '/F2Rest/oauth2/token', [
170+
'auth_basic' => [
171+
$this->options['api_username'],
172+
$this->options['api_secret'],
173+
],
174+
'headers' => [
175+
'accept' => 'application/json',
176+
],
177+
'body' => [
178+
'grant_type' => 'password',
179+
'username' => $this->options['f2_username'],
180+
],
181+
]);
188182

189-
/** @var array{access_token: string, token_type: string} $token */
190-
$token = $response->toArray();
183+
/** @var AccessToken */
184+
$token = $response->toArray();
191185

192-
// @todo Store access token.
186+
$item->expiresAfter((int) $token['expires_in'] - 60);
193187

194-
return $token;
188+
return $token;
189+
});
195190
}
196191

197192
protected function request(string $method, string $path, array $options = []): ResponseInterface
@@ -221,17 +216,21 @@ protected function client(): HttpClientInterface
221216

222217
protected function configureOptions(OptionsResolver $resolver): void
223218
{
224-
$resolver->setRequired([
225-
'api_uri',
226-
'api_username',
227-
'api_secret',
228-
'f2_username',
229-
]);
219+
$resolver
220+
->setRequired([
221+
'api_uri',
222+
'api_username',
223+
'api_secret',
224+
'f2_username',
225+
])
226+
->setDefault('cache_item_lifetime', 86_400)
227+
->setAllowedTypes('cache_item_lifetime', 'int')
228+
->setRequired('cache_item_pool')
229+
->setAllowedTypes('cache_item_pool', CacheItemPoolInterface::class);
230230
}
231231

232232
protected function getRequestUrl(string $rel, array $values): string
233233
{
234-
// @todo Cache this!
235234
$index = $this->getServiceIndex();
236235

237236
$url = $index[$rel]['href'] ?? null;
@@ -242,20 +241,87 @@ protected function getRequestUrl(string $rel, array $values): string
242241
return $this->replacePlaceholders($url, $values);
243242
}
244243

244+
protected function getSearchRequestUrl(string $rel, array $values): string
245+
{
246+
$url = $this->getRequestUrl($rel, []);
247+
248+
try {
249+
$cache = $this->getCache();
250+
$cacheKey = sha1(__METHOD__.'|||'.$rel);
251+
252+
$url = $cache->get($cacheKey, function (CacheItemInterface $item) use ($url) {
253+
$item->expiresAfter((int) $this->options['cache_item_lifetime']);
254+
255+
$response = $this->request(Request::METHOD_GET, $url);
256+
$sxe = new \SimpleXMLElement($response->getContent());
257+
258+
// @mago-ignore analysis:mixed-array-access,non-documented-property
259+
$searchUrl = (string) $sxe->Url['template'];
260+
if (!filter_var($searchUrl, FILTER_VALIDATE_URL)) {
261+
throw new RuntimeException(sprintf('Cannot get search template URL for %s', $url));
262+
}
263+
264+
return $searchUrl;
265+
});
266+
} catch (\Exception $e) {
267+
throw new RuntimeException(sprintf('Cannot get search URL for rel %s', $rel), previous: $e);
268+
}
269+
270+
return $this->replacePlaceholders($url, $values);
271+
}
272+
245273
protected function replacePlaceholders(string $url, array $values): string
246274
{
247275
// Replace URL placeholders ('{…}')
248-
return preg_replace_callback(
276+
return (string) preg_replace_callback(
249277
'/{(?P<name>[^}]+)}/',
250278
static function (array $matches) use ($url, $values): string {
251279
$name = $matches['name'];
252280
if (!array_key_exists($name, $values)) {
253281
throw new RuntimeException(sprintf('Missing value %s for URL %s', $name, $url));
254282
}
255283

256-
return rawurlencode((string)$values[$name]);
284+
return rawurlencode((string) $values[$name]);
257285
},
258286
$url,
259287
);
260288
}
289+
290+
protected function getCache(): CacheInterface
291+
{
292+
/** @var CacheItemPoolInterface $pool */
293+
$pool = $this->options['cache_item_pool'];
294+
295+
return new ProxyAdapter(pool: $pool);
296+
}
297+
298+
/**
299+
* @return Atom[]
300+
*
301+
* @throws \Exception
302+
*/
303+
protected function createSearchResult(ResponseInterface $response): array
304+
{
305+
$items = [];
306+
$sxe = new \SimpleXMLElement($response->getContent());
307+
/** @var \SimpleXMLElement $entry */
308+
// @mago-ignore analysis:non-documented-property
309+
foreach ($sxe->entry as $entry) {
310+
$items[] = Atom::fromSimpleXMLElement($entry);
311+
}
312+
313+
return $items;
314+
}
315+
316+
/**
317+
* @template T of AbstractItem
318+
*
319+
* @param class-string<T> $class
320+
*
321+
* @return T
322+
*/
323+
protected function createItemResult(ResponseInterface $response, string $class): AbstractItem
324+
{
325+
return $class::fromSimpleXMLElement(new \SimpleXMLElement($response->getContent()));
326+
}
261327
}

0 commit comments

Comments
 (0)