Migrate outbound HTTP calls from Guzzle to the Http facade
Replaces every raw GuzzleHttp\Client usage (Nominatim, web.archive.org, webmention fetch/discover/send, Bridgy syndication, IndieAuth client_id lookup, contact avatar/h-card fetch, profile image download, and the CloudConvert screenshot pipeline) with Illuminate\Support\Facades\Http, and enables Http::preventStrayRequests() globally in tests so any un-faked outbound call now fails loudly instead of silently hitting the network. The CloudConvert retry-until-finished middleware in AppServiceProvider is replaced by a plain polling loop in SaveScreenshot, which also fixes a latent bug where the old middleware decoded a response object instead of its body. Bridgy syndication jobs keep their tries=1/no-retry semantics unchanged to avoid duplicate publishes. Guzzle's PSR-7 helpers (Header, UriResolver, Utils) stay in SendWebMentions since Http has no equivalent for them. Every affected test file's Guzzle MockHandler/HandlerStack boilerplate is replaced with Http::fake()/Http::sequence(). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P8j7cJhCiYDsGUgB7obKNQ
This commit is contained in:
parent
c420e19f08
commit
e08763c526
26 changed files with 337 additions and 630 deletions
|
|
@ -4,14 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\FileSystem\FileSystem;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class DownloadWebMention implements ShouldQueue
|
||||
{
|
||||
|
|
@ -29,15 +29,15 @@ class DownloadWebMention implements ShouldQueue
|
|||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
* @throws RequestException
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function handle(Client $guzzle): void
|
||||
public function handle(): void
|
||||
{
|
||||
$response = $guzzle->request('GET', $this->source);
|
||||
// 4XX and 5XX responses should get Guzzle to throw an exception,
|
||||
// Laravel should catch and retry these automatically.
|
||||
if ($response->getStatusCode() === 200) {
|
||||
// 4XX and 5XX responses should throw so Laravel can catch and
|
||||
// retry these automatically.
|
||||
$response = Http::throw()->get($this->source);
|
||||
if ($response->status() === 200) {
|
||||
$filesystem = new FileSystem;
|
||||
$filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source);
|
||||
// backup file first
|
||||
|
|
@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue
|
|||
// save new HTML
|
||||
$filesystem->put(
|
||||
$filename,
|
||||
(string) $response->getBody()
|
||||
$response->body()
|
||||
);
|
||||
// remove backup if the same
|
||||
if ($filesystem->exists($filenameBackup)) {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,13 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Like;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||
|
||||
|
|
@ -32,13 +31,11 @@ class ProcessLike implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(Client $client, Authorship $authorship): int
|
||||
public function handle(Authorship $authorship): int
|
||||
{
|
||||
$response = $client->request('GET', $this->like->url);
|
||||
$mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url);
|
||||
$response = Http::throw()->get($this->like->url);
|
||||
$mf2 = \Mf2\parse($response->body(), $this->like->url);
|
||||
if (Arr::has($mf2, 'items.0.properties.content')) {
|
||||
$this->like->content = $mf2['items'][0]['properties']['content'][0]['html'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ namespace App\Jobs;
|
|||
use App\Exceptions\RemoteContentNotFoundException;
|
||||
use App\Models\Note;
|
||||
use App\Models\WebMention;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
|
||||
use Jonnybarnes\WebmentionsParser\Parser;
|
||||
use Mf2;
|
||||
|
|
@ -36,18 +35,20 @@ class ProcessWebMention implements ShouldQueue
|
|||
* Execute the job.
|
||||
*
|
||||
* @throws RemoteContentNotFoundException
|
||||
* @throws GuzzleException
|
||||
* @throws InvalidMentionException
|
||||
*/
|
||||
public function handle(Parser $parser, Client $guzzle): void
|
||||
public function handle(Parser $parser): void
|
||||
{
|
||||
try {
|
||||
$response = $guzzle->request('GET', $this->source);
|
||||
} catch (RequestException $e) {
|
||||
$response = Http::get($this->source);
|
||||
} catch (ConnectionException) {
|
||||
throw new RemoteContentNotFoundException;
|
||||
}
|
||||
$this->saveRemoteContent((string) $response->getBody(), $this->source);
|
||||
$microformats = Mf2\parse((string) $response->getBody(), $this->source);
|
||||
if ($response->failed()) {
|
||||
throw new RemoteContentNotFoundException;
|
||||
}
|
||||
$this->saveRemoteContent($response->body(), $this->source);
|
||||
$microformats = Mf2\parse($response->body(), $this->source);
|
||||
$webmentions = WebMention::where('source', $this->source)->get();
|
||||
foreach ($webmentions as $webmention) {
|
||||
// check webmention still references target
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||
|
||||
|
|
@ -55,12 +56,10 @@ class SaveProfileImage implements ShouldQueue
|
|||
&& parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com'
|
||||
&& parse_url($photo, PHP_URL_HOST) !== 'twitter.com'
|
||||
) {
|
||||
$client = resolve(Client::class);
|
||||
|
||||
try {
|
||||
$response = $client->get($photo);
|
||||
$image = $response->getBody();
|
||||
} catch (RequestException) {
|
||||
$response = Http::throw()->get($photo);
|
||||
$image = $response->body();
|
||||
} catch (ConnectionException|RequestException) {
|
||||
// we are opening and reading the default image so that
|
||||
$default = public_path().'/assets/profile-images/default-image';
|
||||
$handle = fopen($default, 'rb');
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Bookmark;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use JsonException;
|
||||
|
||||
class SaveScreenshot implements ShouldQueue
|
||||
{
|
||||
|
|
@ -27,77 +28,68 @@ class SaveScreenshot implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// A normal Guzzle client
|
||||
$client = resolve(Client::class);
|
||||
// A Guzzle client with a custom Middleware to retry the CloudConvert API requests
|
||||
$retryClient = resolve('RetryGuzzle');
|
||||
$cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2')
|
||||
->withToken(config('services.cloudconvert.token'))
|
||||
->throw();
|
||||
|
||||
// First request that CloudConvert takes a screenshot of the URL
|
||||
$takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
|
||||
],
|
||||
'json' => [
|
||||
'url' => $this->bookmark->url,
|
||||
'output_format' => 'png',
|
||||
'screen_width' => 1440,
|
||||
'screen_height' => 900,
|
||||
'wait_until' => 'networkidle0',
|
||||
'wait_time' => 100,
|
||||
],
|
||||
$takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [
|
||||
'url' => $this->bookmark->url,
|
||||
'output_format' => 'png',
|
||||
'screen_width' => 1440,
|
||||
'screen_height' => 900,
|
||||
'wait_until' => 'networkidle0',
|
||||
'wait_time' => 100,
|
||||
]);
|
||||
|
||||
$taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
|
||||
$taskId = $takeScreenshotJobResponse->json('data.id');
|
||||
|
||||
// Now wait till the status job is finished
|
||||
$screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$taskId, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
|
||||
],
|
||||
'query' => [
|
||||
'include' => 'payload',
|
||||
],
|
||||
]);
|
||||
$screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId);
|
||||
|
||||
$finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
|
||||
$finishedCaptureId = $screenshotJobStatusResponse->json('data.id');
|
||||
|
||||
// Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from
|
||||
$exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
|
||||
],
|
||||
'json' => [
|
||||
'input' => $finishedCaptureId,
|
||||
'archive_multiple_files' => false,
|
||||
],
|
||||
$exportImageJob = $cloudConvert->post('/export/url', [
|
||||
'input' => $finishedCaptureId,
|
||||
'archive_multiple_files' => false,
|
||||
]);
|
||||
|
||||
$exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
|
||||
$exportImageJobId = $exportImageJob->json('data.id');
|
||||
|
||||
// Again, wait till the status of this export job is finished
|
||||
$finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$exportImageJobId, [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
|
||||
],
|
||||
'query' => [
|
||||
'include' => 'payload',
|
||||
],
|
||||
]);
|
||||
$finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId);
|
||||
|
||||
// Now we can download the screenshot and save it to the storage
|
||||
$finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url;
|
||||
$finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url');
|
||||
|
||||
$finalImageUrlContent = $client->request('GET', $finalImageUrl);
|
||||
$finalImageUrlContent = Http::throw()->get($finalImageUrl);
|
||||
|
||||
Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->getBody()->getContents());
|
||||
Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body());
|
||||
|
||||
$this->bookmark->screenshot = $taskId;
|
||||
$this->bookmark->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a CloudConvert task until it reports a "finished" status.
|
||||
*/
|
||||
private function pollUntilFinished(PendingRequest $client, string $taskId): Response
|
||||
{
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
$response = $client->get('/tasks/'.$taskId, ['include' => 'payload']);
|
||||
$finished = $response->json('data.status') === 'finished';
|
||||
if (! $finished) {
|
||||
$attempts++;
|
||||
usleep(1_000_000); // 1 second, matches CloudConvert's own polling guidance
|
||||
}
|
||||
} while (! $finished && $attempts < 5);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Note;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Header;
|
||||
use GuzzleHttp\Psr7\UriResolver;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
|
|
@ -14,6 +12,7 @@ use Illuminate\Bus\Queueable;
|
|||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Mf2\Parser;
|
||||
|
||||
|
|
@ -32,8 +31,6 @@ class SendWebMentions implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
|
|
@ -43,12 +40,9 @@ class SendWebMentions implements ShouldQueue
|
|||
foreach ($urls as $url) {
|
||||
$endpoint = $this->discoverWebmentionEndpoint($url);
|
||||
if ($endpoint !== null) {
|
||||
$guzzle = resolve(Client::class);
|
||||
$guzzle->post($endpoint, [
|
||||
'form_params' => [
|
||||
'source' => $this->note->uri,
|
||||
'target' => $url,
|
||||
],
|
||||
Http::asForm()->post($endpoint, [
|
||||
'source' => $this->note->uri,
|
||||
'target' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,8 +50,6 @@ class SendWebMentions implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Discover if a URL has a webmention endpoint.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function discoverWebmentionEndpoint(string $url): ?string
|
||||
{
|
||||
|
|
@ -71,10 +63,9 @@ class SendWebMentions implements ShouldQueue
|
|||
|
||||
$endpoint = null;
|
||||
|
||||
$guzzle = resolve(Client::class);
|
||||
$response = $guzzle->get($url);
|
||||
$response = Http::get($url);
|
||||
// check HTTP Headers for webmention endpoint
|
||||
$links = Header::parse($response->getHeader('Link'));
|
||||
$links = Header::parse($response->header('Link'));
|
||||
foreach ($links as $link) {
|
||||
if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) {
|
||||
return $this->resolveUri(trim($link[0], '<>'), $url);
|
||||
|
|
@ -82,7 +73,7 @@ class SendWebMentions implements ShouldQueue
|
|||
}
|
||||
|
||||
// failed to find a header so parse HTML
|
||||
$html = (string) $response->getBody();
|
||||
$html = $response->body();
|
||||
|
||||
if ($html === '') {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Note;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SyndicateNoteToBluesky implements ShouldQueue
|
||||
{
|
||||
|
|
@ -31,29 +30,18 @@ class SyndicateNoteToBluesky implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(Client $guzzle): void
|
||||
public function handle(): void
|
||||
{
|
||||
$response = $guzzle->request(
|
||||
'POST',
|
||||
'https://brid.gy/publish/webmention',
|
||||
[
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'form_params' => [
|
||||
'source' => $this->note->uri,
|
||||
'target' => 'https://brid.gy/publish/bluesky',
|
||||
],
|
||||
'http_errors' => false,
|
||||
]
|
||||
);
|
||||
// no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
|
||||
$response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
|
||||
'source' => $this->note->uri,
|
||||
'target' => 'https://brid.gy/publish/bluesky',
|
||||
]);
|
||||
|
||||
$body = json_decode((string) $response->getBody(), true);
|
||||
$body = $response->json();
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
if ($response->status() === 201) {
|
||||
$this->note->bluesky_url = $body['url'];
|
||||
$this->note->save();
|
||||
|
||||
|
|
@ -61,7 +49,7 @@ class SyndicateNoteToBluesky implements ShouldQueue
|
|||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody())
|
||||
'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Note;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class SyndicateNoteToMastodon implements ShouldQueue
|
||||
{
|
||||
|
|
@ -31,29 +30,18 @@ class SyndicateNoteToMastodon implements ShouldQueue
|
|||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function handle(Client $guzzle): void
|
||||
public function handle(): void
|
||||
{
|
||||
$response = $guzzle->request(
|
||||
'POST',
|
||||
'https://brid.gy/publish/webmention',
|
||||
[
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'form_params' => [
|
||||
'source' => $this->note->uri,
|
||||
'target' => 'https://brid.gy/publish/mastodon',
|
||||
],
|
||||
'http_errors' => false,
|
||||
]
|
||||
);
|
||||
// no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
|
||||
$response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
|
||||
'source' => $this->note->uri,
|
||||
'target' => 'https://brid.gy/publish/mastodon',
|
||||
]);
|
||||
|
||||
$body = json_decode((string) $response->getBody(), true);
|
||||
$body = $response->json();
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
if ($response->status() === 201) {
|
||||
$this->note->mastodon_url = $body['url'];
|
||||
$this->note->save();
|
||||
|
||||
|
|
@ -61,7 +49,7 @@ class SyndicateNoteToMastodon implements ShouldQueue
|
|||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody())
|
||||
'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue