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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeAhora que tenemos una idea de lo que necesitamos que haga 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 obtendrá todas las "ediciones" 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
Vale, ¿y ahora qué? Volviendo al 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, establece $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, comprueba si el título de la incidencia contiene el $dinosaurName. Así queif (str_contains($issue['title'], $dinosaurName)) entonces haremos // 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 para nuestro dinosaurio. ¡Guau! Ahora tenemos que hacer un bucle sobre cada etiqueta para ver si encontramos el estado de salud. Para ayudarte, 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 HealthStatus correcto basado en esa etiqueta.
Ahora, en lugar de // Do Something, dice$health = $this->getDinoStatusFromLabels() y pasa 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 por defecto $healthen HealthStatus::HEALTHY - porque GenLab nunca olvidaría poner una etiquetaSick a 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 afirmaciones, ¡y todas pasan! ¡Shweeet!
Registra todas nuestras peticiones
¡Un último reto! Para facilitar 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 obtener el servicio logger. Añade un constructor conprivate LoggerInterface $logger para añadir un argumento y una propiedad de una 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 ejecutemos las pruebas para estar seguros:
./vendor/bin/phpunit
Y... ¡Ay! ¡Sí que hemos roto algo!
Demasiados pocos argumentos pasados al constructor en GithubService. se esperaban 0 pasados 1.
¡Por supuesto! Cuando añadimos el argumento LoggerInterface a GithubService, nunca actualizamos nuestra prueba para pasarlo. A continuación te mostraré cómo podemos hacerlo utilizando una de las superhabilidades de PHPUnit: el mocking.
4 Comments
Wouldn't it be better to use the Symfony\Contracts\HttpClient\HttpClientInterface? If not, what would be the disadvantages?
Yo @S-H!
Do you mean: would it be better to autowire
HttpClientInterfaceas an argument to the constructor of the service instead of instantiating a fresh one inside? If so, then yes :). We actually do it in the next chapter - we just didn't do it here because doing that would require mocking in the test, and we weren't quite ready yet. But in general, allowing it to be passed in is typically better because you can mock it in your tests.Cheers!
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?
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!
"Houston: no signs of life"
Start the conversation!