phase 1 and 2
This commit is contained in:
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\DTO;
|
||||
|
||||
use DateTime;
|
||||
|
||||
class JobData
|
||||
{
|
||||
public function __construct(
|
||||
public string $externalId,
|
||||
public string $title,
|
||||
public string $company,
|
||||
public string $description,
|
||||
public ?string $location = null,
|
||||
public ?float $salaryMin = null,
|
||||
public ?float $salaryMax = null,
|
||||
public string $salaryCurrency = 'USD',
|
||||
public ?string $jobType = null,
|
||||
public ?string $sourceUrl = null,
|
||||
public ?string $applyUrl = null,
|
||||
public ?DateTime $postedAt = null,
|
||||
public ?DateTime $expiresAt = null,
|
||||
public bool $remoteAllowed = false,
|
||||
public ?string $experienceLevel = null,
|
||||
public ?string $category = null,
|
||||
public ?array $rawData = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Convert to array for database insertion
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'external_id' => $this->externalId,
|
||||
'title' => $this->title,
|
||||
'company' => $this->company,
|
||||
'description' => $this->description,
|
||||
'location' => $this->location,
|
||||
'salary_min' => $this->salaryMin,
|
||||
'salary_max' => $this->salaryMax,
|
||||
'salary_currency' => $this->salaryCurrency,
|
||||
'job_type' => $this->jobType,
|
||||
'source_url' => $this->sourceUrl,
|
||||
'apply_url' => $this->applyUrl,
|
||||
'posted_at' => $this->postedAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
'remote_allowed' => $this->remoteAllowed,
|
||||
'experience_level' => $this->experienceLevel,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fingerprint for deduplication
|
||||
*/
|
||||
public function generateFingerprint(): string
|
||||
{
|
||||
$data = strtolower(trim($this->title . '|' . $this->company . '|' . $this->location));
|
||||
return hash('sha256', $data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ApiException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $apiSource,
|
||||
public readonly ?array $context = null,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getContext(): array
|
||||
{
|
||||
return array_merge([
|
||||
'api_source' => $this->apiSource,
|
||||
'message' => $this->getMessage(),
|
||||
'code' => $this->getCode(),
|
||||
], $this->context ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Exceptions;
|
||||
|
||||
class RateLimitException extends ApiException
|
||||
{
|
||||
public function __construct(
|
||||
string $apiSource,
|
||||
public readonly ?int $retryAfter = null,
|
||||
array $context = []
|
||||
) {
|
||||
$message = "Rate limit exceeded for {$apiSource}";
|
||||
if ($this->retryAfter) {
|
||||
$message .= ". Retry after {$this->retryAfter} seconds";
|
||||
}
|
||||
|
||||
parent::__construct($message, $apiSource, $context, 429);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Exceptions;
|
||||
|
||||
class TransformationException extends ApiException
|
||||
{
|
||||
public function __construct(
|
||||
string $apiSource,
|
||||
string $message = 'Failed to transform API response',
|
||||
array $context = []
|
||||
) {
|
||||
parent::__construct($message, $apiSource, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi;
|
||||
|
||||
use App\Models\JobSource;
|
||||
use App\Services\JobApi\Clients\AdzunaClient;
|
||||
use App\Services\JobApi\Clients\JobApiClient;
|
||||
use App\Services\JobApi\Clients\RemotiveClient;
|
||||
use App\Services\JobApi\Clients\TheMuseClient;
|
||||
use App\Services\JobApi\Exceptions\ApiException;
|
||||
|
||||
class JobApiClientFactory
|
||||
{
|
||||
/**
|
||||
* Client class mappings
|
||||
*/
|
||||
private const CLIENT_MAP = [
|
||||
'remotive' => RemotiveClient::class,
|
||||
'the-muse' => TheMuseClient::class,
|
||||
'adzuna' => AdzunaClient::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an API client for the given source
|
||||
*
|
||||
* @param JobSource $source
|
||||
* @return JobApiClient
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function make(JobSource $source): JobApiClient
|
||||
{
|
||||
$sourceName = strtolower($source->name);
|
||||
$normalizedName = self::normalizeSourceName($sourceName);
|
||||
|
||||
if (!isset(self::CLIENT_MAP[$normalizedName])) {
|
||||
throw new ApiException(
|
||||
"No client implementation found for source: {$source->name}",
|
||||
$source->name,
|
||||
['available_clients' => array_keys(self::CLIENT_MAP)]
|
||||
);
|
||||
}
|
||||
|
||||
$clientClass = self::CLIENT_MAP[$normalizedName];
|
||||
|
||||
return new $clientClass($source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create clients for all active sources
|
||||
*
|
||||
* @return array<JobApiClient>
|
||||
*/
|
||||
public static function makeAll(): array
|
||||
{
|
||||
$sources = JobSource::active()->get();
|
||||
|
||||
return $sources->map(function (JobSource $source) {
|
||||
try {
|
||||
return self::make($source);
|
||||
} catch (ApiException $e) {
|
||||
// Log the error but continue with other sources
|
||||
\Log::warning("Failed to create client for source: {$source->name}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
})->filter()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client by source name
|
||||
*
|
||||
* @param string $sourceName
|
||||
* @return JobApiClient
|
||||
* @throws ApiException
|
||||
*/
|
||||
public static function makeByName(string $sourceName): JobApiClient
|
||||
{
|
||||
$source = JobSource::where('name', $sourceName)
|
||||
->orWhere('name', 'LIKE', "%{$sourceName}%")
|
||||
->firstOrFail();
|
||||
|
||||
return self::make($source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available client types
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getAvailableClients(): array
|
||||
{
|
||||
return array_keys(self::CLIENT_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize source name for lookup
|
||||
*/
|
||||
private static function normalizeSourceName(string $name): string
|
||||
{
|
||||
// Convert "Remotive", "remotive.io", "Remotive.io" to "remotive"
|
||||
$name = strtolower($name);
|
||||
$name = str_replace(['.io', '.com', ' '], '', $name);
|
||||
|
||||
// Handle special cases
|
||||
$name = match (true) {
|
||||
str_contains($name, 'muse') => 'the-muse',
|
||||
str_contains($name, 'remotive') => 'remotive',
|
||||
str_contains($name, 'adzuna') => 'adzuna',
|
||||
default => $name,
|
||||
};
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Transformers;
|
||||
|
||||
use App\Services\JobApi\DTO\JobData;
|
||||
use App\Services\JobApi\Exceptions\TransformationException;
|
||||
use DateTime;
|
||||
|
||||
class AdzunaTransformer implements JobTransformer
|
||||
{
|
||||
public function transform(array $data): JobData
|
||||
{
|
||||
try {
|
||||
return new JobData(
|
||||
externalId: (string) $data['id'],
|
||||
title: $data['title'] ?? 'Untitled',
|
||||
company: $data['company']['display_name'] ?? 'Unknown Company',
|
||||
description: $this->sanitizeDescription($data['description'] ?? ''),
|
||||
location: $this->parseLocation($data),
|
||||
salaryMin: $data['salary_min'] ?? null,
|
||||
salaryMax: $data['salary_max'] ?? null,
|
||||
salaryCurrency: 'USD',
|
||||
jobType: $this->mapJobType($data['contract_type'] ?? null),
|
||||
sourceUrl: $data['redirect_url'] ?? null,
|
||||
applyUrl: $data['redirect_url'] ?? null,
|
||||
postedAt: $this->parseDate($data['created'] ?? null),
|
||||
expiresAt: null,
|
||||
remoteAllowed: $this->isRemote($data),
|
||||
experienceLevel: null,
|
||||
category: $data['category']['label'] ?? null,
|
||||
rawData: $data,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new TransformationException(
|
||||
'Adzuna',
|
||||
'Failed to transform Adzuna job data: ' . $e->getMessage(),
|
||||
['data' => $data, 'error' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function transformMany(array $jobs): array
|
||||
{
|
||||
return array_map(fn($job) => $this->transform($job), $jobs);
|
||||
}
|
||||
|
||||
private function sanitizeDescription(string $description): string
|
||||
{
|
||||
$clean = strip_tags($description);
|
||||
$clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$clean = preg_replace('/\s+/', ' ', $clean);
|
||||
return trim($clean);
|
||||
}
|
||||
|
||||
private function parseLocation(array $data): ?string
|
||||
{
|
||||
$location = $data['location']['display_name'] ?? null;
|
||||
|
||||
if (empty($location)) {
|
||||
// Fallback to area
|
||||
$area = $data['location']['area'] ?? [];
|
||||
if (!empty($area)) {
|
||||
return implode(', ', $area);
|
||||
}
|
||||
}
|
||||
|
||||
return $location;
|
||||
}
|
||||
|
||||
private function isRemote(array $data): bool
|
||||
{
|
||||
$title = strtolower($data['title'] ?? '');
|
||||
$description = strtolower($data['description'] ?? '');
|
||||
$location = strtolower($data['location']['display_name'] ?? '');
|
||||
|
||||
return str_contains($title, 'remote') ||
|
||||
str_contains($description, 'remote') ||
|
||||
str_contains($location, 'remote');
|
||||
}
|
||||
|
||||
private function parseDate(?string $date): ?DateTime
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new DateTime($date);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function mapJobType(?string $type): ?string
|
||||
{
|
||||
if (empty($type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower($type)) {
|
||||
'permanent', 'full_time' => 'full-time',
|
||||
'part_time' => 'part-time',
|
||||
'contract' => 'contract',
|
||||
'temporary' => 'temporary',
|
||||
default => 'full-time',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Transformers;
|
||||
|
||||
use App\Services\JobApi\DTO\JobData;
|
||||
|
||||
interface JobTransformer
|
||||
{
|
||||
/**
|
||||
* Transform a single job from API response to JobData DTO
|
||||
*
|
||||
* @param array $data Raw job data from API
|
||||
* @return JobData Standardized job data
|
||||
*/
|
||||
public function transform(array $data): JobData;
|
||||
|
||||
/**
|
||||
* Transform multiple jobs from API response
|
||||
*
|
||||
* @param array $jobs Array of raw job data from API
|
||||
* @return array<JobData> Array of standardized job data
|
||||
*/
|
||||
public function transformMany(array $jobs): array;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Transformers;
|
||||
|
||||
use App\Services\JobApi\DTO\JobData;
|
||||
use App\Services\JobApi\Exceptions\TransformationException;
|
||||
use DateTime;
|
||||
|
||||
class RemotiveTransformer implements JobTransformer
|
||||
{
|
||||
public function transform(array $data): JobData
|
||||
{
|
||||
try {
|
||||
return new JobData(
|
||||
externalId: (string) $data['id'],
|
||||
title: $data['title'] ?? 'Untitled',
|
||||
company: $data['company_name'] ?? 'Unknown Company',
|
||||
description: $this->sanitizeDescription($data['description'] ?? ''),
|
||||
location: $this->parseLocation($data),
|
||||
salaryMin: $this->parseSalary($data['salary'] ?? null, 'min'),
|
||||
salaryMax: $this->parseSalary($data['salary'] ?? null, 'max'),
|
||||
salaryCurrency: 'USD',
|
||||
jobType: $this->mapJobType($data['job_type'] ?? null),
|
||||
sourceUrl: $data['url'] ?? null,
|
||||
applyUrl: $data['url'] ?? null,
|
||||
postedAt: $this->parseDate($data['publication_date'] ?? null),
|
||||
expiresAt: null,
|
||||
remoteAllowed: true, // Remotive only has remote jobs
|
||||
experienceLevel: $this->mapExperienceLevel($data['candidate_required_location'] ?? null),
|
||||
category: $data['category'] ?? null,
|
||||
rawData: $data,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new TransformationException(
|
||||
'Remotive',
|
||||
'Failed to transform Remotive job data: ' . $e->getMessage(),
|
||||
['data' => $data, 'error' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function transformMany(array $jobs): array
|
||||
{
|
||||
return array_map(fn($job) => $this->transform($job), $jobs);
|
||||
}
|
||||
|
||||
private function sanitizeDescription(string $description): string
|
||||
{
|
||||
// Remove HTML tags and clean up whitespace
|
||||
$clean = strip_tags($description);
|
||||
$clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$clean = preg_replace('/\s+/', ' ', $clean);
|
||||
return trim($clean);
|
||||
}
|
||||
|
||||
private function parseLocation(array $data): ?string
|
||||
{
|
||||
$location = $data['candidate_required_location'] ?? null;
|
||||
|
||||
if (empty($location)) {
|
||||
return 'Remote';
|
||||
}
|
||||
|
||||
return $location;
|
||||
}
|
||||
|
||||
private function parseSalary(?string $salary, string $type): ?float
|
||||
{
|
||||
if (empty($salary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract numbers from salary string
|
||||
if (preg_match_all('/[\d,]+/', $salary, $matches)) {
|
||||
$numbers = array_map(fn($n) => (float) str_replace(',', '', $n), $matches[0]);
|
||||
|
||||
if (count($numbers) >= 2) {
|
||||
return $type === 'min' ? min($numbers) : max($numbers);
|
||||
} elseif (count($numbers) === 1) {
|
||||
return $numbers[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseDate(?string $date): ?DateTime
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new DateTime($date);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function mapJobType(?string $type): ?string
|
||||
{
|
||||
if (empty($type)) {
|
||||
return 'full-time';
|
||||
}
|
||||
|
||||
return match (strtolower($type)) {
|
||||
'full_time', 'full-time' => 'full-time',
|
||||
'part_time', 'part-time' => 'part-time',
|
||||
'contract' => 'contract',
|
||||
'freelance' => 'freelance',
|
||||
default => 'full-time',
|
||||
};
|
||||
}
|
||||
|
||||
private function mapExperienceLevel(?string $location): ?string
|
||||
{
|
||||
// Remotive doesn't provide experience level directly
|
||||
// This is a placeholder that could be enhanced with ML/NLP
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\JobApi\Transformers;
|
||||
|
||||
use App\Services\JobApi\DTO\JobData;
|
||||
use App\Services\JobApi\Exceptions\TransformationException;
|
||||
use DateTime;
|
||||
|
||||
class TheMuseTransformer implements JobTransformer
|
||||
{
|
||||
public function transform(array $data): JobData
|
||||
{
|
||||
try {
|
||||
return new JobData(
|
||||
externalId: (string) $data['id'],
|
||||
title: $data['name'] ?? 'Untitled',
|
||||
company: $data['company']['name'] ?? 'Unknown Company',
|
||||
description: $this->sanitizeDescription($data['contents'] ?? ''),
|
||||
location: $this->parseLocation($data),
|
||||
salaryMin: null, // The Muse doesn't provide salary info
|
||||
salaryMax: null,
|
||||
salaryCurrency: 'USD',
|
||||
jobType: $this->mapJobType($data['type'] ?? null),
|
||||
sourceUrl: $data['refs']['landing_page'] ?? null,
|
||||
applyUrl: $data['refs']['landing_page'] ?? null,
|
||||
postedAt: $this->parseDate($data['publication_date'] ?? null),
|
||||
expiresAt: null,
|
||||
remoteAllowed: $this->isRemote($data),
|
||||
experienceLevel: $this->mapExperienceLevel($data['levels'] ?? []),
|
||||
category: $this->parseCategory($data['categories'] ?? []),
|
||||
rawData: $data,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new TransformationException(
|
||||
'The Muse',
|
||||
'Failed to transform The Muse job data: ' . $e->getMessage(),
|
||||
['data' => $data, 'error' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function transformMany(array $jobs): array
|
||||
{
|
||||
return array_map(fn($job) => $this->transform($job), $jobs);
|
||||
}
|
||||
|
||||
private function sanitizeDescription(string $description): string
|
||||
{
|
||||
$clean = strip_tags($description);
|
||||
$clean = html_entity_decode($clean, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$clean = preg_replace('/\s+/', ' ', $clean);
|
||||
return trim($clean);
|
||||
}
|
||||
|
||||
private function parseLocation(array $data): ?string
|
||||
{
|
||||
if (empty($data['locations'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$locations = array_map(fn($loc) => $loc['name'] ?? '', $data['locations']);
|
||||
return implode(', ', array_filter($locations));
|
||||
}
|
||||
|
||||
private function isRemote(array $data): bool
|
||||
{
|
||||
if (empty($data['locations'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($data['locations'] as $location) {
|
||||
$name = strtolower($location['name'] ?? '');
|
||||
if (str_contains($name, 'remote') || str_contains($name, 'flexible')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function parseDate(?string $date): ?DateTime
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new DateTime($date);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function mapJobType(?string $type): ?string
|
||||
{
|
||||
if (empty($type)) {
|
||||
return 'full-time';
|
||||
}
|
||||
|
||||
return match (strtolower($type)) {
|
||||
'full-time', 'fulltime' => 'full-time',
|
||||
'part-time', 'parttime' => 'part-time',
|
||||
'contract' => 'contract',
|
||||
'temporary', 'temp' => 'temporary',
|
||||
'internship', 'intern' => 'internship',
|
||||
'freelance' => 'freelance',
|
||||
default => 'full-time',
|
||||
};
|
||||
}
|
||||
|
||||
private function mapExperienceLevel(array $levels): ?string
|
||||
{
|
||||
if (empty($levels)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The Muse uses: Entry Level, Mid Level, Senior Level
|
||||
$firstLevel = strtolower($levels[0]['name'] ?? '');
|
||||
|
||||
return match (true) {
|
||||
str_contains($firstLevel, 'entry') => 'entry',
|
||||
str_contains($firstLevel, 'mid') => 'mid',
|
||||
str_contains($firstLevel, 'senior') => 'senior',
|
||||
str_contains($firstLevel, 'executive') => 'executive',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function parseCategory(array $categories): ?string
|
||||
{
|
||||
if (empty($categories)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the first category name
|
||||
return $categories[0]['name'] ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user