Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Servicio GitHub: Implementación

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

Ahora que tenemos una idea de lo que necesitamos que haga el GithubService, vamos a añadir la lógica interna que obtendrá las incidencias del repositorio dino-park utilizando la API de GitHub.

Añade el cliente y haz una petición

Para hacer peticiones HTTP, en tu terminal, instala el Cliente HTTP de Symfony con:

composer require symfony/http-client

Dentro de GithubService, instala un cliente HTTP con$client = HttpClient::create(). Para hacer una petición, llama a $client->request(). Esto necesita 2 cosas. 1ª: qué método HTTP utilizar, como GET o POST. En este caso, debería ser GET. 2ª: la URL, que pegaré. Esto recuperará todos los "issues" del repositorio dino-park a través de la API de GitHub.

... 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
}
}

Analiza la respuesta HTTP

Bien, ¿y ahora qué? Mirando el repositorio dino-park, GitHub devolverá una respuesta JSON que contiene las incidencias que vemos aquí. Cada incidencia tiene un título con el nombre de un dino y si la incidencia tiene una etiqueta adjunta, también la obtendremos de vuelta. Así que, pon $client->request() en una nueva variable $response. A continuación, foreach()sobre $response->toArray() como $issue. Lo bueno de utilizar el cliente HTTP de Symfony es que no tenemos que molestarnos en transformar el JSON de GitHub en una matriz - toArray() hace ese trabajo pesado por nosotros. Dentro de este bucle, comprobamos si el título de la incidencia contiene el $dinosaurName. Así queif (str_contains($issue['title'], $dinosaurName)) entonces // Do Somethingcon esa incidencia.

... 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
}
}

Llegados a este punto, hemos encontrado la incidencia de nuestro dinosaurio. ¡Vaya! Ahora tenemos que hacer un bucle sobre cada etiqueta para ver si podemos encontrar el estado de salud. Para ayudarte, voy a pegar un método privado: puedes copiarlo del bloque de código de esta página.

... 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);
}
}

Esto toma una matriz de etiquetas... y cuando encuentra una que empieza por Status:, devuelve el enum correcto HealthStatus basado en esa etiqueta.

Ahora, en lugar de // Do Something, decimos$health = $this->getDinoStatusFromLabels() y pasamos las etiquetas con $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

Y ahora podemos devolver $health. Pero... ¿qué pasa si un número no tiene una etiqueta de estado de salud? Hmm... al principio de este método, establece el valor por defecto $healtha HealthStatus::HEALTHY - porque GenLab nunca se olvidaría de poner una etiquetaSick en un dino que no se encuentra bien.

... 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... Bueno, ¡creo que lo hemos conseguido! Hagamos nuestras pruebas para estar seguros.

./vendor/bin/phpunit

Y... ¡Vaya! Tenemos 8 pruebas, 11 aserciones, ¡y todas pasan! ¡Shweeet!

Registra todas nuestras peticiones

¡Un último reto! Para ayudar a la depuración, quiero registrar un mensaje cada vez que hagamos una petición a la API de GitHub.

¡No hay problema! Sólo tenemos que conseguir el servicio de registro. Añade un constructor conprivate LoggerInterface $logger para añadir un argumento y una propiedad de una sola vez. Justo después de llamar al método request(), añade $this->logger->info() y pasaRequest Dino Issues para el mensaje y también un array con contexto extra. ¿Qué tal una clave dino establecida en $dinosaurName y responseStatus en$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
}

¡Genial! Eso no debería haber roto nada en nuestra clase, pero vamos a ejecutar las pruebas para estar seguros:

./vendor/bin/phpunit

Y... ¡Ay! ¡Sí que hemos roto algo!

Se han pasado muy pocos argumentos al constructor de GithubService. se esperaban 0 pasados 1.

¡Por supuesto! Cuando añadimos el argumento LoggerInterface a GithubService, nunca actualizamos nuestro test para pasarlo. Te mostraré cómo podemos hacerlo a continuación utilizando una de las super habilidades de PHPUnit: el 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