phase 1 and 2

This commit is contained in:
Doar Mihai
2026-04-03 00:23:01 +03:00
parent 4976742c29
commit 9dcf3c36d7
33 changed files with 2563 additions and 5 deletions
@@ -0,0 +1,176 @@
<?php
namespace App\Services\JobApi\Clients;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\ApiException;
use App\Services\JobApi\Transformers\AdzunaTransformer;
use App\Services\JobApi\Transformers\JobTransformer;
use Illuminate\Http\Client\PendingRequest;
class AdzunaClient extends JobApiClient
{
private string $appId;
private string $appKey;
private string $country = 'us'; // Default to US
protected function getTransformer(): JobTransformer
{
return new AdzunaTransformer();
}
protected function attachAuthentication(PendingRequest $client): PendingRequest
{
// Parse API key as "app_id:app_key"
if ($this->source->api_key && str_contains($this->source->api_key, ':')) {
[$this->appId, $this->appKey] = explode(':', $this->source->api_key, 2);
} else {
throw new ApiException(
'Adzuna API key must be in format "app_id:app_key"',
$this->source->name
);
}
// Extract country from configuration if set
if (!empty($this->source->configuration['country'])) {
$this->country = $this->source->configuration['country'];
}
return $client;
}
/**
* Fetch jobs from Adzuna API
*
* @param array $params [
* 'what' => string, // Job title or keywords
* 'where' => string, // Location
* 'results_per_page' => int,
* 'page' => int,
* 'category' => string,
* 'full_time' => int, // 1 for full-time only
* 'part_time' => int, // 1 for part-time only
* 'contract' => int, // 1 for contract only
* 'max_days_old' => int, // Jobs posted within last N days
* ]
* @return array<JobData>
*/
public function fetchJobs(array $params = []): array
{
try {
$queryParams = $this->buildQueryParams($params);
// Adzuna API endpoint structure: /v1/api/jobs/{country}/search/{page}
$page = $queryParams['page'] ?? 1;
unset($queryParams['page']);
$endpoint = "search/{$page}";
$response = $this->makeRequest('GET', $endpoint, $queryParams);
$data = $response->json();
if (!isset($data['results']) || !is_array($data['results'])) {
throw new ApiException(
'Invalid response structure from Adzuna API',
$this->source->name,
['response' => $data]
);
}
$this->updateLastFetched();
return $this->transformer->transformMany($data['results']);
} catch (ApiException $e) {
throw $e;
} catch (\Throwable $e) {
throw new ApiException(
'Failed to fetch jobs from Adzuna',
$this->source->name,
['error' => $e->getMessage()],
0,
$e
);
}
}
/**
* Build query parameters for the API request
*/
private function buildQueryParams(array $params): array
{
$queryParams = [
'app_id' => $this->appId,
'app_key' => $this->appKey,
'results_per_page' => min($params['results_per_page'] ?? 20, 50),
'page' => $params['page'] ?? 1,
];
if (!empty($params['what'])) {
$queryParams['what'] = $params['what'];
}
if (!empty($params['where'])) {
$queryParams['where'] = $params['where'];
}
if (isset($params['category'])) {
$queryParams['category'] = $params['category'];
}
if (isset($params['full_time'])) {
$queryParams['full_time'] = (int) $params['full_time'];
}
if (isset($params['part_time'])) {
$queryParams['part_time'] = (int) $params['part_time'];
}
if (isset($params['contract'])) {
$queryParams['contract'] = (int) $params['contract'];
}
if (isset($params['max_days_old'])) {
$queryParams['max_days_old'] = (int) $params['max_days_old'];
}
return $queryParams;
}
/**
* Override buildUrl to include country in path
*/
protected function buildUrl(string $endpoint): string
{
$baseUrl = rtrim($this->source->api_url, '/');
$endpoint = ltrim($endpoint, '/');
return "{$baseUrl}/{$this->country}/{$endpoint}";
}
/**
* Get supported countries
*/
public function getSupportedCountries(): array
{
return [
'au' => 'Australia',
'at' => 'Austria',
'br' => 'Brazil',
'ca' => 'Canada',
'fr' => 'France',
'de' => 'Germany',
'in' => 'India',
'it' => 'Italy',
'nl' => 'Netherlands',
'nz' => 'New Zealand',
'pl' => 'Poland',
'ru' => 'Russia',
'sg' => 'Singapore',
'za' => 'South Africa',
'ch' => 'Switzerland',
'gb' => 'United Kingdom',
'us' => 'United States',
];
}
}
@@ -0,0 +1,186 @@
<?php
namespace App\Services\JobApi\Clients;
use App\Models\JobSource;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\ApiException;
use App\Services\JobApi\Exceptions\RateLimitException;
use App\Services\JobApi\Transformers\JobTransformer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
abstract class JobApiClient
{
protected PendingRequest $client;
protected JobTransformer $transformer;
protected int $maxRetries = 3;
protected int $retryDelay = 1000; // milliseconds
public function __construct(
protected JobSource $source
) {
$this->client = $this->buildHttpClient();
$this->transformer = $this->getTransformer();
}
/**
* Build the HTTP client with base configuration
*/
protected function buildHttpClient(): PendingRequest
{
$client = Http::timeout(30)
->retry($this->maxRetries, $this->retryDelay, function ($exception, $request) {
// Retry on server errors (5xx) but not on client errors (4xx)
if ($exception instanceof \Illuminate\Http\Client\RequestException) {
$statusCode = $exception->response?->status();
return $statusCode >= 500 && $statusCode < 600;
}
return false;
})
->withHeaders($this->getHeaders());
if ($this->source->api_key) {
$client = $this->attachAuthentication($client);
}
return $client;
}
/**
* Get the transformer instance for this API
*/
abstract protected function getTransformer(): JobTransformer;
/**
* Get default headers for API requests
*/
protected function getHeaders(): array
{
return [
'Accept' => 'application/json',
'User-Agent' => 'JobifyGarden/1.0',
];
}
/**
* Attach authentication to the HTTP client
*/
abstract protected function attachAuthentication(PendingRequest $client): PendingRequest;
/**
* Fetch jobs from the API
*
* @param array $params Query parameters (keywords, location, page, etc.)
* @return array<JobData> Array of standardized job data
*/
abstract public function fetchJobs(array $params = []): array;
/**
* Make an API request with error handling and logging
*/
protected function makeRequest(string $method, string $endpoint, array $params = []): Response
{
$url = $this->buildUrl($endpoint);
Log::info("API Request: {$this->source->name}", [
'method' => $method,
'url' => $url,
'params' => $params,
]);
try {
$response = match (strtoupper($method)) {
'GET' => $this->client->get($url, $params),
'POST' => $this->client->post($url, $params),
default => throw new ApiException(
"Unsupported HTTP method: {$method}",
$this->source->name
),
};
if ($response->failed()) {
$this->handleFailedResponse($response);
}
Log::info("API Response: {$this->source->name}", [
'status' => $response->status(),
'url' => $url,
]);
return $response;
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new ApiException(
"Connection failed to {$this->source->name}",
$this->source->name,
['error' => $e->getMessage()],
0,
$e
);
}
}
/**
* Build full URL from endpoint
*/
protected function buildUrl(string $endpoint): string
{
$baseUrl = rtrim($this->source->api_url, '/');
$endpoint = ltrim($endpoint, '/');
return "{$baseUrl}/{$endpoint}";
}
/**
* Handle failed API responses
*/
protected function handleFailedResponse(Response $response): void
{
$statusCode = $response->status();
if ($statusCode === 429) {
$retryAfter = $response->header('Retry-After');
throw new RateLimitException(
$this->source->name,
$retryAfter ? (int) $retryAfter : null,
['response' => $response->body()]
);
}
throw new ApiException(
"API request failed with status {$statusCode}",
$this->source->name,
[
'status' => $statusCode,
'response' => $response->body(),
],
$statusCode
);
}
/**
* Get the source name
*/
public function getSourceName(): string
{
return $this->source->name;
}
/**
* Check if the API is available
*/
public function isAvailable(): bool
{
return $this->source->is_active;
}
/**
* Update the last fetched timestamp
*/
protected function updateLastFetched(): void
{
$this->source->update(['last_fetched_at' => now()]);
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Services\JobApi\Clients;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\ApiException;
use App\Services\JobApi\Transformers\JobTransformer;
use App\Services\JobApi\Transformers\RemotiveTransformer;
use Illuminate\Http\Client\PendingRequest;
class RemotiveClient extends JobApiClient
{
protected function getTransformer(): JobTransformer
{
return new RemotiveTransformer();
}
protected function attachAuthentication(PendingRequest $client): PendingRequest
{
// Remotive API doesn't require authentication
return $client;
}
/**
* Fetch jobs from Remotive API
*
* @param array $params [
* 'search' => string, // Job title keywords
* 'company_name' => string,
* 'category' => string, // e.g., 'software-dev', 'customer-support'
* 'limit' => int, // Number of results (default 50)
* ]
* @return array<JobData>
*/
public function fetchJobs(array $params = []): array
{
try {
$queryParams = $this->buildQueryParams($params);
$response = $this->makeRequest('GET', 'remote-jobs', $queryParams);
$data = $response->json();
if (!isset($data['jobs']) || !is_array($data['jobs'])) {
throw new ApiException(
'Invalid response structure from Remotive API',
$this->source->name,
['response' => $data]
);
}
$this->updateLastFetched();
return $this->transformer->transformMany($data['jobs']);
} catch (ApiException $e) {
throw $e;
} catch (\Throwable $e) {
throw new ApiException(
'Failed to fetch jobs from Remotive',
$this->source->name,
['error' => $e->getMessage()],
0,
$e
);
}
}
/**
* Build query parameters for the API request
*/
private function buildQueryParams(array $params): array
{
$queryParams = [];
if (!empty($params['search'])) {
$queryParams['search'] = $params['search'];
}
if (!empty($params['company_name'])) {
$queryParams['company_name'] = $params['company_name'];
}
if (!empty($params['category'])) {
$queryParams['category'] = $params['category'];
}
if (!empty($params['limit'])) {
$queryParams['limit'] = min((int) $params['limit'], 100);
}
return $queryParams;
}
/**
* Get available job categories from Remotive
*/
public function getCategories(): array
{
// Remotive categories (as of API documentation)
return [
'software-dev',
'customer-support',
'design',
'marketing',
'sales',
'product',
'business',
'data',
'devops',
'finance',
'hr',
'legal',
'operations',
'qa',
'writing',
];
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Services\JobApi\Clients;
use App\Services\JobApi\DTO\JobData;
use App\Services\JobApi\Exceptions\ApiException;
use App\Services\JobApi\Transformers\JobTransformer;
use App\Services\JobApi\Transformers\TheMuseTransformer;
use Illuminate\Http\Client\PendingRequest;
class TheMuseClient extends JobApiClient
{
protected function getTransformer(): JobTransformer
{
return new TheMuseTransformer();
}
protected function attachAuthentication(PendingRequest $client): PendingRequest
{
// The Muse API v2 doesn't require authentication for basic queries
// If API key is provided, attach it as a query parameter
if ($this->source->api_key) {
$client = $client->withQueryParameters(['api_key' => $this->source->api_key]);
}
return $client;
}
/**
* Fetch jobs from The Muse API
*
* @param array $params [
* 'page' => int, // Page number (default 0)
* 'descending' => bool, // Sort order (default true)
* 'category' => string, // Job category
* 'level' => string, // e.g., 'Entry Level', 'Mid Level', 'Senior Level'
* 'location' => string, // Location filter
* 'company' => string, // Company name or ID
* ]
* @return array<JobData>
*/
public function fetchJobs(array $params = []): array
{
try {
$queryParams = $this->buildQueryParams($params);
$response = $this->makeRequest('GET', 'jobs', $queryParams);
$data = $response->json();
if (!isset($data['results']) || !is_array($data['results'])) {
throw new ApiException(
'Invalid response structure from The Muse API',
$this->source->name,
['response' => $data]
);
}
$this->updateLastFetched();
return $this->transformer->transformMany($data['results']);
} catch (ApiException $e) {
throw $e;
} catch (\Throwable $e) {
throw new ApiException(
'Failed to fetch jobs from The Muse',
$this->source->name,
['error' => $e->getMessage()],
0,
$e
);
}
}
/**
* Build query parameters for the API request
*/
private function buildQueryParams(array $params): array
{
$queryParams = [
'page' => $params['page'] ?? 0,
'descending' => $params['descending'] ?? true,
];
if (!empty($params['category'])) {
$queryParams['category'] = $params['category'];
}
if (!empty($params['level'])) {
$queryParams['level'] = $params['level'];
}
if (!empty($params['location'])) {
$queryParams['location'] = $params['location'];
}
if (!empty($params['company'])) {
$queryParams['company'] = $params['company'];
}
return $queryParams;
}
/**
* Get available job categories from The Muse
*/
public function getCategories(): array
{
return [
'Account Management',
'Business & Strategy',
'Creative & Design',
'Customer Service',
'Data Science',
'Editorial',
'Education',
'Engineering',
'Finance',
'Healthcare & Medicine',
'HR & Recruiting',
'Legal',
'Marketing & PR',
'Operations',
'Project & Product Management',
'Retail',
'Sales',
'Social Media & Community',
];
}
/**
* Get available experience levels
*/
public function getLevels(): array
{
return [
'Internship',
'Entry Level',
'Mid Level',
'Senior Level',
'Executive',
];
}
}