Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

GitHub Service: Implementation

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Now that we have an idea of what we need the GithubService to do, let's add the logic inside that will fetch the issues from the dino-park repository using GitHub's API.

Add the client and make a request

To make HTTP requests, at your terminal, install Symfony's HTTP Client with:

composer require symfony/http-client

Inside of GithubService, instantiate an HTTP client with $client = HttpClient::create(). To make a request, call $client->request(). This needs 2 things. 1st: what HTTP method to use, like GET or POST. In this case, it should be GET. 2nd: the URL, which I'll paste in. This will fetch all of the "issues" from the dino-park repository via GitHub's API.

... lines 1 - 5
use Symfony\Component\HttpClient\HttpClient;
class GithubService
{
public function getHealthReport(string $dinosaurName): HealthStatus
{
... lines 12 - 13
$client = HttpClient::create();
$response = $client->request(
method: 'GET',
url: 'https://api.github.com/repos/SymfonyCasts/dino-park/issues'
);
... lines 20 - 27
}
}

Parse the HTTP Response

Ok, now what? Looking back at the dino-park repo, GitHub will return a JSON response that contains the issues we see here. Each issue has a title with a dino's name and if the issue has a label attached to it, we'll get that back too. So, set $client->request() to a new $response variable. Then, below, foreach() over $response->toArray() as an $issue. The cool thing about using Symfony's HTTP Client is that we don't have to bother transforming the JSON from GitHub into an array - toArray() does that heavy lifting for us. Inside this loop, check if the issue title contains the $dinosaurName. So if (str_contains($issue['title'], $dinosaurName)) then we'll // Do Something with that issue.

... lines 1 - 5
use Symfony\Component\HttpClient\HttpClient;
class GithubService
{
public function getHealthReport(string $dinosaurName): HealthStatus
{
... lines 12 - 13
$client = HttpClient::create();
$response = $client->request(
method: 'GET',
url: 'https://api.github.com/repos/SymfonyCasts/dino-park/issues'
);
foreach ($response->toArray() as $issue) {
if (str_contains($issue['title'], $dinosaurName)) {
}
}
... lines 26 - 27
}
}

At this point, we've found the issue for our dinosaur. Woo! Now we need to loop over each label to see if we can find the health status. To help, I'll paste in a private method: you can copy this from the code block on this page.

... lines 1 - 4
use App\Enum\HealthStatus;
... lines 6 - 7
class GithubService
{
... lines 10 - 29
private function getDinoStatusFromLabels(array $labels): HealthStatus
{
$status = null;
foreach ($labels as $label) {
$label = $label['name'];
// We only care about "Status" labels
if (!str_starts_with($label, 'Status:')) {
continue;
}
// Remove the "Status:" and whitespace from the label
$status = trim(substr($label, strlen('Status:')));
}
return HealthStatus::tryFrom($status);
}
}

This takes an array of labels... and when it finds one that starts with Status:, it returns the correct HealthStatus enum based on that label.

Now instead of // Do Something, say $health = $this->getDinoStatusFromLabels() and pass the labels with $issue['labels'].

... lines 1 - 5
use Symfony\Component\HttpClient\HttpClient;
class GithubService
{
public function getHealthReport(string $dinosaurName): HealthStatus
{
... lines 12 - 13
$client = HttpClient::create();
$response = $client->request(
method: 'GET',
url: 'https://api.github.com/repos/SymfonyCasts/dino-park/issues'
);
foreach ($response->toArray() as $issue) {
if (str_contains($issue['title'], $dinosaurName)) {
$health = $this->getDinoStatusFromLabels($issue['labels']);
}
}
... lines 26 - 27
}
... lines 30 - 49

And now we can return $health. But... what if an issue doesn't have a health status label? Hmm... at the beginning of this method, set the default $health to HealthStatus::HEALTHY - because GenLab would never forget to put a Sick label on a dino that isn't feeling well.

... lines 1 - 7
class GithubService
{
public function getHealthReport(string $dinosaurName): HealthStatus
{
$health = HealthStatus::HEALTHY;
$client = HttpClient::create();
$response = $client->request(
method: 'GET',
url: 'https://api.github.com/repos/SymfonyCasts/dino-park/issues'
);
foreach ($response->toArray() as $issue) {
if (str_contains($issue['title'], $dinosaurName)) {
$health = $this->getDinoStatusFromLabels($issue['labels']);
}
}
return $health;
}
... lines 29 - 49

Hmm... Welp, I think we did it! Let's run our tests to be sure.

./vendor/bin/phpunit

And... Wow! We have 8 tests, 11 assertions, and they're all passing! Shweeet!

Log all of our requests

One last challenge! To help debugging, I want to log a message each time we make a request to the GitHub API.

No problem! We just need to get the logger service. Add a constructor with private LoggerInterface $logger to add an argument and property all at once. Right after we call the request() method, add $this->logger->info() and pass Request Dino Issues for the message and also an array with extra context. How about a dino key set to $dinosaurName and responseStatus to $response->getStatusCode().

... lines 1 - 5
use Psr\Log\LoggerInterface;
... lines 7 - 8
class GithubService
{
public function __construct(private LoggerInterface $logger)
{
}
public function getHealthReport(string $dinosaurName): HealthStatus
{
... lines 17 - 25
$this->logger->info('Request Dino Issues', [
'dino' => $dinosaurName,
'responseStatus' => $response->getStatusCode(),
]);
... lines 30 - 37
}
... lines 39 - 57
}

Cool! That shouldn't have broken anything in our class, but let's run the tests to be sure:

./vendor/bin/phpunit

And... Ouch! We did break something!

Too few arguments passed to the constructor in GithubService. 0 passed 1 expected.

Of course! When we added the LoggerInterface argument to GithubService, we never updated our test to pass that in. I'll show you how we can do that next using one of PHPUnit's super abilities: mocking.

Leave a comment!

2
Login or Register to join the conversation

Is it ok to write unit test for the service that uses a real API request, as the test can fail if the data will change on the external resource?

Reply

Hey @maMykola!

It depends on the situation, but in these situations, sometimes I write ONLY a unit test, sometimes ONLY an integration test, and sometimes both :p. Let's look at it:

A) Suppose I'm using an API that I trust - e.g. Stripe - where there is VERY little chance that they would ever do something silly and accidentally change their API. But, the data I get back from the API is pretty complex and I do some pretty complex stuff with it. In this case, I might only unit test that service: I would mock the API, fake the response, and test that my handling is correct.

B) Now suppose that I'm using an API that I do NOT trust: it's a smaller company, or they have a reputation of doing silly things, or it's some internal thing another company made for you that might just change one day. But, the data I get back from them is very simple and I don't do a lot of processing on it. In this case, I might ONLY do an integration test: I would (if possible) make a test that ACTUALLY hits their API and makes sure that I get back the data I expect. I'm not really testing my code in this case... I'm testing that their API didn't do anything silly :p. But you're totally right that testing an external resource is HARD. Sometimes it's just not feasible. And even if it is, you need to be very careful because, as you said, the data may change on that external resource. And so, I typically make my assertions very "generic". I may assert that the JSON I receive back "has" a certain key but I may not assert the value of that key, as it may change.

And if I have a combination of (A) and (B), I might have both a unit and integration test. I guess I'm realizing that (A) a unit test (where you mock the API) is a way for you to test YOUR logic of what you do with the data from the API. And (B) an integration test (where you use the real API) is a way for you to test that your assumptions about what the API will return are correct.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.4
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.3
        "symfony/framework-bundle": "6.1.*", // v6.1.4
        "symfony/http-client": "6.1.*", // v6.1.4
        "symfony/runtime": "6.1.*", // v6.1.3
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/yaml": "6.1.*" // v6.1.4
    },
    "require-dev": {
        "phpunit/phpunit": "^9.5", // 9.5.23
        "symfony/browser-kit": "6.1.*", // v6.1.3
        "symfony/css-selector": "6.1.*", // v6.1.3
        "symfony/phpunit-bridge": "^6.1" // v6.1.3
    }
}
userVoice