using System.Net.Http.Headers; using System.Text.Json; using JobTrackerApi.Models; using Microsoft.Extensions.Caching.Memory; namespace JobTrackerApi.Services; public interface IJobDiscoverySearchService { Task> SearchAsync(string? query, string? location, CancellationToken cancellationToken); } public sealed class NavJobDiscoverySearchService( IHttpClientFactory clients, IConfiguration configuration, IMemoryCache cache, TimeProvider timeProvider) : IJobDiscoverySearchService { private const string BaseUrl = "https://pam-stilling-feed.nav.no"; public async Task> SearchAsync(string? query, string? location, CancellationToken cancellationToken) { var retrievedAt = timeProvider.GetUtcNow(); var token = await GetTokenAsync(cancellationToken); var client = clients.CreateClient(); var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); var next = "/api/v1/feed"; 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 = timeProvider.GetUtcNow().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 jobId = feed.GetProperty("uuid").GetString(); if (string.IsNullOrWhiteSpace(jobId)) continue; var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null; if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) { entries.Remove(jobId); continue; } entries[jobId] = new DiscoveredJob(jobId, 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, feed.TryGetProperty("applicationDue", out var due) && due.TryGetDateTimeOffset(out var deadline) ? deadline : null, $"https://arbeidsplassen.nav.no/stillinger/stilling/{jobId}", "nav", "NAV Arbeidsplassen", "searched", retrievedAt, "NO"); } } var term = (query ?? "").Trim(); var place = (location ?? "").Trim(); return entries.Values.Where(job => Contains(job.Title, term) || Contains(job.Company, term)) .Where(job => Contains(job.Location, place)).OrderByDescending(job => job.ModifiedAt).Take(100).ToList(); } private async Task 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); }