Skip to content

Commit 93e95c1

Browse files
committed
Added API client
1 parent 2053f1d commit 93e95c1

11 files changed

Lines changed: 375 additions & 4 deletions

File tree

.gitignore

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
composer.lock
2-
vendor/
3-
41
###> friendsofphp/php-cs-fixer ###
52
/.php-cs-fixer.cache
63
###< friendsofphp/php-cs-fixer ###
4+
5+
*.local
6+
.idea
7+
composer.lock
8+
vendor/

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,50 @@
22

33
API client for [The cBrain F2 Platform](https://www.cbrain.com/software-pages/the-f2-platform).
44

5+
## Installation
6+
7+
``` shell
8+
composer require itk-dev/f2-api-client
9+
```
10+
11+
## Testing
12+
13+
Edit `.env.local` and set these variables:
14+
15+
``` dotenv
16+
# .env.local
17+
F2_API_URI=
18+
F2_API_USERNAME=
19+
F2_API_SECRET=
20+
F2_F2_USERNAME=
21+
```
22+
23+
Run
24+
25+
``` shell
26+
docker compose run --quiet --rm phpfpm composer install
27+
docker compose run --quiet --rm phpfpm php bin/f2-api-client getServiceIndex
28+
```
29+
30+
to check that you can talk to the F2 API.
31+
32+
Search cases with
33+
34+
``` shell
35+
docker compose run --quiet --rm phpfpm php bin/f2-api-client searchCases '{"q": "test", "count": 10}'
36+
```
37+
38+
## Development
39+
40+
For development (and testing) a couple of useful tasks are defined:
41+
42+
``` text
43+
* f2-api-client:debug: Debug bin/f2-api-client inside docker compose setup, e.g. `task f2-api-client:debug -- searchCases '{"q": "test", "count": 10}'`
44+
* f2-api-client:run: Run bin/f2-api-client inside docker compose setup, e.g. `task f2-api-client:run -- searchCases '{"q": "test", "count": 10}'`
45+
```
46+
47+
---
48+
549
``` mermaid
650
---
751
title: F2 Conceptual model
@@ -10,6 +54,11 @@ title: F2 Conceptual model
1054
classDiagram
1155
Document --> Matter
1256
Matter --> CaseFile
57+
Party
58+
Note --> CaseFile: (only if not related to Matter)
59+
Note --> Matter: (only if not related to CaseFile)
60+
61+
Chat --> Matter
1362
1463
%% class Item{
1564
%% +String title
@@ -19,3 +68,8 @@ classDiagram
1968
%% Item <|-- Matter
2069
%% Item <|-- CaseFile
2170
```
71+
72+
73+
> The client MUST read the service index at runtime and use it to locate the links it needs.
74+
75+
<https://github.com/itk-dev/f2-api-client/blob/main/resources/f2-rest-docs-v13.pdf#page=8>

Taskfile.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,17 @@ tasks:
9696

9797
mago:format: *mago_command
9898
mago:analyze: *mago_command
99+
100+
f2-api-client:run:
101+
desc: 'Run bin/f2-api-client inside docker compose setup, e.g. `task {{.TASK}} -- searchCases ''{"q": "test", "count": 10}''`'
102+
cmds:
103+
- task: compose
104+
vars:
105+
TASK_ARGS: run --quiet --rm phpfpm php bin/f2-api-client
106+
107+
f2-api-client:debug:
108+
desc: 'Debug bin/f2-api-client inside docker compose setup, e.g. `task {{.TASK}} -- searchCases ''{"q": "test", "count": 10}''`'
109+
cmds:
110+
- task: compose
111+
vars:
112+
TASK_ARGS: run --quiet --env PHP_XDEBUG_MODE=debug --rm phpfpm php bin/f2-api-client

bin/f2-api-client

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
// @see https://getcomposer.org/doc/articles/vendor-binaries.md#finding-the-composer-autoloader-from-a-binary
5+
require $_composer_autoload_path ?? __DIR__ . '/../vendor/autoload.php';
6+
7+
use Symfony\Component\Console\Application;
8+
9+
$application = new Application('f2-api-client', '1.0.0');
10+
$command = new \ItkDev\F2ApiClient\Command\F2ApiClientCommand();
11+
12+
$application->addCommand($command);
13+
14+
$application->setDefaultCommand('f2:api:client', true);
15+
$application->run();

compose.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ services:
99
- PHP_IDE_CONFIG=serverName=localhost
1010
volumes:
1111
- .:/app
12+
# https://docs.docker.com/compose/how-tos/environment-variables/set-environment-variables/#additional-information-1
13+
env_file:
14+
- path: .env.local
15+
required: false
1216

1317
# Code checks tools
1418
markdownlint:

composer.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
"description": "F2 API client",
44
"license": "MIT",
55
"type": "library",
6+
"require": {
7+
"ext-simplexml": "*",
8+
"symfony/console": "^6.4 || ^7.4",
9+
"symfony/http-client": "^6.4 || ^7.4",
10+
"symfony/options-resolver": "^6.4 || ^7.4"
11+
},
612
"require-dev": {
13+
"php": "^8.3",
714
"ergebnis/composer-normalize": "^2.52",
815
"friendsofphp/php-cs-fixer": "^3.95"
916
},
@@ -12,6 +19,9 @@
1219
"ItkDev\\F2ApiClient\\": "src/"
1320
}
1421
},
22+
"bin": [
23+
"bin/f2-api-client"
24+
],
1525
"config": {
1626
"allow-plugins": {
1727
"ergebnis/composer-normalize": true

src/Client/ApiClient.php

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ItkDev\F2ApiClient\Client;
6+
7+
use ItkDev\F2ApiClient\Exception\RuntimeException;
8+
use ItkDev\F2ApiClient\Model\CaseFile;
9+
use Symfony\Component\HttpClient\HttpClient;
10+
use Symfony\Component\OptionsResolver\OptionsResolver;
11+
use Symfony\Contracts\HttpClient\HttpClientInterface;
12+
use Symfony\Contracts\HttpClient\ResponseInterface;
13+
14+
class ApiClient
15+
{
16+
protected const string METHOD_GET = 'GET';
17+
protected const string METHOD_POST = 'POST';
18+
19+
private readonly array $options;
20+
private ?HttpClientInterface $client = null;
21+
22+
public function __construct(array $options)
23+
{
24+
$resolver = new OptionsResolver();
25+
$this->configureOptions($resolver);
26+
27+
$this->options = $resolver->resolve($options);
28+
}
29+
30+
/**
31+
* @return array<string, array{href: string, title: string}>
32+
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
33+
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
34+
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
35+
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
36+
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
37+
*/
38+
public function getServiceIndex(): array
39+
{
40+
$path = '/F2Rest/ServiceIndex';
41+
$response = $this->client()->request(self::METHOD_GET, $path, [
42+
'headers' => [
43+
'Accept' => 'application/json',
44+
],
45+
]);
46+
47+
return $response->toArray();
48+
}
49+
50+
/**
51+
* @param array $query
52+
* @return CaseFile[]
53+
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
54+
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
55+
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
56+
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
57+
*/
58+
public function searchCases(array $query): array
59+
{
60+
// $query = [];
61+
$path = '/F2Rest/search/cases';
62+
// @todo /F2Rest/ServiceIndex.json reports
63+
//
64+
// "http://cbrain.com/casefile/rel/case-search": {
65+
// "href": "/F2Rest/search/cases",
66+
// "title": "Case file search"
67+
// },
68+
//
69+
// which refers to the actual search URL: /F2Rest/searches/cases
70+
$path = '/F2Rest/searches/cases';
71+
// $path = $this->getRequestUrl('http://cbrain.com/casefile/rel/case-search');
72+
$response = $this->request(self::METHOD_GET, $path, [
73+
'query' => $query,
74+
]);
75+
76+
$items = [];
77+
$sxe = new \SimpleXMLElement($response->getContent());
78+
foreach ($sxe->entry as $entry) {
79+
$items[] = new CaseFile($entry);
80+
}
81+
82+
return $items;
83+
}
84+
85+
/**
86+
* @return array{access_token: string, token_type: string}
87+
*/
88+
protected function getAccessToken(): array
89+
{
90+
// @todo Check existing access token is not expired.
91+
92+
$client = $this->client();
93+
$response = $client->request(self::METHOD_POST, '/F2Rest/oauth2/token', [
94+
'auth_basic' => [
95+
$this->options['api_username'],
96+
$this->options['api_secret'],
97+
],
98+
'headers' => [
99+
'accept' => 'application/json',
100+
],
101+
'body' => [
102+
'grant_type' => 'password',
103+
'username' => $this->options['f2_username'],
104+
],
105+
]);
106+
107+
/** @var array{access_token: string, token_type: string} $token */
108+
$token = $response->toArray();
109+
110+
// @todo Store access token.
111+
112+
return $token;
113+
}
114+
115+
protected function request(string $method, string $path, array $options = []): ResponseInterface
116+
{
117+
$accessToken = $this->getAccessToken();
118+
119+
return $this->client()->request(
120+
$method,
121+
$path,
122+
$options
123+
+ [
124+
'auth_bearer' => $accessToken['access_token'],
125+
],
126+
);
127+
}
128+
129+
protected function client(): HttpClientInterface
130+
{
131+
if (null === $this->client) {
132+
$this->client = HttpClient::create([
133+
'base_uri' => $this->options['api_uri'],
134+
]);
135+
}
136+
137+
return $this->client;
138+
}
139+
140+
protected function configureOptions(OptionsResolver $resolver): void
141+
{
142+
$resolver->setRequired([
143+
'api_uri',
144+
'api_username',
145+
'api_secret',
146+
'f2_username',
147+
]);
148+
}
149+
150+
protected function getRequestUrl(string $rel): string
151+
{
152+
// @todo Cache this!
153+
$index = $this->getServiceIndex();
154+
155+
$url = $index[$rel]['href'] ?? null;
156+
if (null === $url) {
157+
throw new RuntimeException(sprintf('Cannot get rel %s', $rel));
158+
}
159+
160+
return $url;
161+
}
162+
}

src/Command/F2ApiClientCommand.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ItkDev\F2ApiClient\Command;
6+
7+
use ItkDev\F2ApiClient\Client\ApiClient;
8+
use Symfony\Component\Console\Attribute\Argument;
9+
use Symfony\Component\Console\Attribute\AsCommand;
10+
use Symfony\Component\Console\Command\Command;
11+
use Symfony\Component\Console\Exception\InvalidArgumentException;
12+
use Symfony\Component\Console\Exception\RuntimeException;
13+
use Symfony\Component\Console\Style\SymfonyStyle;
14+
15+
#[AsCommand(name: 'f2:api:client')]
16+
class F2ApiClientCommand
17+
{
18+
public function __invoke(SymfonyStyle $io, #[Argument] string $action, #[Argument] ?string $arg = null): int
19+
{
20+
$client = $this->createClient();
21+
22+
$getArray = static function (?string $value): array {
23+
if (null === $value) {
24+
throw new InvalidArgumentException('Missing JSON argument');
25+
}
26+
try {
27+
$array = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
28+
} catch (\JsonException $e) {
29+
throw new InvalidArgumentException(sprintf('Invalid JSON: %s', $e->getMessage()));
30+
}
31+
32+
return $array;
33+
};
34+
35+
$response = match ($action) {
36+
'getServiceIndex' => $client->getServiceIndex(),
37+
'searchCases' => $client->searchCases($getArray($arg)),
38+
default => throw new InvalidArgumentException(sprintf('Invalid action: %s', $action)),
39+
};
40+
41+
$io->writeln((string) json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
42+
43+
return Command::SUCCESS;
44+
}
45+
46+
private function createClient(): ApiClient
47+
{
48+
$getEnv = static function (string $name): string {
49+
$value = getenv($name);
50+
if (false === $value || '' === trim($value)) {
51+
throw new RuntimeException(sprintf('Cannot read environment variable %s', $name));
52+
}
53+
54+
return $value;
55+
};
56+
57+
$config = [
58+
'api_uri' => $getEnv('F2_API_URI'),
59+
'api_username' => $getEnv('F2_API_USERNAME'),
60+
'api_secret' => $getEnv('F2_API_SECRET'),
61+
'f2_username' => $getEnv('F2_F2_USERNAME'),
62+
];
63+
64+
return new ApiClient($config);
65+
}
66+
}

src/Exception/Exception.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ItkDev\F2ApiClient\Exception;
6+
7+
class Exception extends \Exception
8+
{
9+
}

0 commit comments

Comments
 (0)