kbsali / php-redmine-api

A simple PHP Redmine API client, Object Oriented

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Idea] New CommandClient (High-level API), split Client and Configuration

Art4 opened this issue · comments

Using the Redmine API there are some famous tasks like create an issue, add a note to an issue, list projects. Every task has other requirements and one has to check to Redmine wiki to learn about the parameter types and possibilities.

I'm working on the idea of a new client that allows an IDE to suggest the required and optional parameters for typical Redmine tasks. I would call this the high-level API.

Basics

The basic will be a minimalistic HTTP client and Request/Response interfaces described in #341 and a new Command interface.

interface HttpClient
{
    public function request(Request $request): Response;
}

interface Command
{
    public function execute(): Response;
}

Create Client from Configuration

The main task of a HttpClient is to send a request with the correct headers (authentication, content-type, impersonation) and return the response. However we have the possibility to adjust this headers using the methods startImpersonateUser(), stopImpersonateUser() and authentication with $apiKey or username/password. This allows to change the state of the client, which can lead to race conditions.

I propose to split this changeable parts into a new Configuration class, leaving the Client in an unchangeable state.

$config = Configuration::from($redmineUrl, $httpClient, $requestFactory, $streamFactory)
    // using authentication is optional
    ->authenticate($apiKey)
    ->withUserImpersonation('kim')
;

// or with native curl support
$config = Configuration::fromNativeCurl($redmineUrl);

// Create the client
$client = $config->createClient();

// you can reuse the config to create different client instances
$config->authenticateWithUsernameAndPassword($username, $password)
    ->withoutUserImpersonation()
;

$clientWithoutImpersonation = $config->createClient();

This allows us to add new features in Configuration without changing the API of the Client.

$client = Configuration::from($redmineUrl, $httpClient, $requestFactory, $streamFactory)
    ->authenticate($apiKey)
    // possible feature: set the redmine version
    ->withRedmineVersion('5.0.0')
    // possible feature: set a PSR-6 cache pool
    ->withCache($cachePool)
    ->createClient()
;

Command concepts

The created client implements the HttpClient interface, but also have the mid-level API getApi() method. Additionally we could add methods for typical tasks, that returns a Command implementation like CreateProjectCommand. This CreateProjectCommand class requires all required parameter and allows to set optional parameters.

$client->createProject('Project name', 'project_identifier')
    ->withDescription('a project description')
    ->asPublic(false)
    ->execute();

The Command implementation could also be created independent from a HttpClient but requires it in the factory method.

$createProjectCommand = CreateProjectCommand::from($httpClient, 'Project name', 'project_identifier')
    ->withDescription('a project description')
    ->asPublic(false);

$createProjectCommand->execute();

Adding custom fields might become straight forward.

$client->createGroup($groupName)
    ->withCustomField($fieldId, $value)
    // or by field name; requires a front up request to list all custom fields
    ->withCustomFieldByName($fieldName, $value)
    ->execute();

The Command implementation could also have a switch to use the XML or JSON endpoint, to address #146.

$client->updateIssue($issueId)
    ->withNote('add this text as note in the issue')
    ->useXmlEndpoint()
    // ->useJsonEndpoint()
    ->execute();

Return data

Every Command will declares a execute(): Response method, but could also declare more methods like asArray().

final class ListIssuesCommand implements Command
{
    // [...] other implementation details not shown

    public function execute(): Response
    {
        return $this->httpClient->request(JsonRequest::get(PathSerializer::create($this->endpoint, $this->params)));
    }

    public function asArray(): array
    {
        return $this->responseToArray($this->execute());
    }
}

Other data formats would be an implementation detail and will not need an interface.

final class ListUsersCommand implements Command
{
    // [...] other implementation details not shown

    /**
     * @return array<int,string> list of users (id => 'username')
     */
    public function getAsListing(): array
    {
        return $this->responseToListingArray($this->execute());
    }
}

We have to make sure that calling execute() would not rerun a request, but only return an already cached response.

Forward compatibility

If a new Redmine version adds new parameter the commands should have generic methods like ->with() or ->filter() to add such parameters without the need to update this library.

$client->createUser($login, $firestname, $lastname, $mail)
    ->withPassword($password)
    // assume brand new `fediverse_handle` parameter
    ->with('fediverse_handle', '@username@example.com')
    ->execute();

$client->listUsers()
    ->filterByStatus(1)
    // assume brand new `project_id` filter
    ->filter('project_id', $projectId)
    ->execute();

The new filter and parameters could then be implemented later as methods.

$client->createUser($login, $firestname, $lastname, $mail)
    ->withPassword($password)
    // implement brand new `fediverse_handle` parameter
    ->withFediverseHandle('@username@example.com')
    ->execute();

$client->listUsers()
    ->filterByStatus(1)
    // implement brand new `project_id` filter
    ->filterByProjectId($projectId)
    ->execute();

I might became suitable to let the user define the Redmine version of the server to provide more information about available features or deprecations.

$client = $config->setRedmineVersion('3.0.0')->createClient();

$client->listMyAccountData()->execute();
// should throw Exception with message "endpoint `/my/account.json` requires Redmine 4.1.0 and is not supported on Redmine 3.0.0."

Example code

This code shows examples how the new Configuration and Client would look like if used by the users:

$config = Configuration::fromNativeCurl($redmineUrl)
    ->withRedmineVersion('5.0.0')
    ->withCache($cachePool)
    ->authenticateWithUsernameAndPassword($username, $password)
;

$config = Configuration::from($redmineUrl, $httpClient, $requestFactory, $streamFactory)
    ->authenticate($apiKey)
    ->withUserImpersonation('kim')
    ->withoutUserImpersonation()
;

$client = $config->createClient();

// low-level API
$client->request(new JsonRequest::get('/issues.json', ['limit' => 100]));
$client->request(new JsonRequest::post('/issues.json', ['issue' => ['project_id' => 4, 'title' => 'bug report title']]));
$client->request(new XmlRequest::postRaw('/issues.xml', '{"issue":{"project_id":4,"title":"bug report title"}}'));
$client->request(new FileRequest::postRaw(['/uploads.json', ['filename' => 'awesome.jpg']], file_get_contents('awesome.jpg')));
// mid-level API
$client->getApi('issue')->create(['project_id' => 4, 'title' => 'bug report title']);
// high-level API
$client->createIssue(project_id: 4)->withTitle('bug report title')->asJson()->execute();
// or
$command = CreateIssueCommand::create(client: $client, project_id: 4)->withTitle('bug report title');
$reponse = $command->execute();


$client = Configuration::fromNativeCurl($redmineUrl)->authenticate($apiKey)->createClient();
$client->getApi('issue')->create(['project_id' => 4, 'title' => 'bug report title']);

$issues = $client->getApi('issue')->list(['limit' => 100]);
$anonymousClient = Configuration::fromNativeCurl($redmineUrl)->createClient();
$issues = $client->listIssues()->withLimit(100)->asArray();


$impersonatingClient = Configuration::fromNativeCurl($redmineUrl)->authenticate($apiKey)->impersonateUser('kim')->createClient();

$oldRedmine = Configuration::fromNativeCurl($redmineUrl1)->authenticate($apiKey1)->createClient();
$newRedmine = Configuration::fromNativeCurl($redmineUrl2)->authenticate($apiKey2)->createClient();

$issues = $oldRedmine->listIssues($projectId)->withLimit(1000)->asArray();

foreach($issues['issues'] as $issue)
{
    $newRedmine->createIssue($projectId)
        ->withTitle($issue['title'])
        ->withDescription($issue['description'])
        ->asPublic($issue['is_public'])
        ->execute()
    ;
}