feat: add NAV job discovery
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/job-discovery")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class JobDiscoveryController : ControllerBase
|
||||
{
|
||||
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
|
||||
private readonly IHttpClientFactory _clients;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public JobDiscoveryController(IHttpClientFactory clients, IConfiguration configuration, IMemoryCache cache)
|
||||
{
|
||||
_clients = clients;
|
||||
_configuration = configuration;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<IReadOnlyList<DiscoveredJob>>> Search([FromQuery] string? q, [FromQuery] string? location, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = await GetTokenAsync(cancellationToken);
|
||||
var client = _clients.CreateClient();
|
||||
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
|
||||
var next = "/api/v1/feed";
|
||||
|
||||
// ponytail: scan the recent event window on demand; add a persisted feed cursor only when
|
||||
// usage makes the bounded request noticeably slow or NAV private-token terms require it.
|
||||
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (page == 0) request.Headers.IfModifiedSince = DateTimeOffset.UtcNow.AddDays(-14);
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
|
||||
|
||||
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
var feed = item.GetProperty("_feed_entry");
|
||||
var id = feed.GetProperty("uuid").GetString();
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
|
||||
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entries.Remove(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[id] = new DiscoveredJob(
|
||||
id,
|
||||
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
|
||||
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
|
||||
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
|
||||
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
|
||||
$"https://arbeidsplassen.nav.no/stillinger/stilling/{id}",
|
||||
"nav",
|
||||
"NO");
|
||||
}
|
||||
}
|
||||
|
||||
var query = (q ?? "").Trim();
|
||||
var place = (location ?? "").Trim();
|
||||
return Ok(entries.Values
|
||||
.Where(job => Contains(job.Title, query) || Contains(job.Company, query))
|
||||
.Where(job => Contains(job.Location, place))
|
||||
.OrderByDescending(job => job.ModifiedAt)
|
||||
.Take(100)
|
||||
.ToList());
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
|
||||
{
|
||||
return Problem("NAV job discovery is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configured = _configuration["NavJobs:Token"]?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||||
|
||||
return await _cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
|
||||
var text = await _clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
|
||||
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
|
||||
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
|
||||
var token = text[start..].Trim();
|
||||
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
|
||||
return end < 0 ? token : token[..end];
|
||||
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string filter) =>
|
||||
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
public sealed record DiscoveredJob(string Id, string Title, string? Company, string? Location, DateTimeOffset? ModifiedAt, string Url, string Source, string CountryCode);
|
||||
}
|
||||
Reference in New Issue
Block a user