This adds five new free API integrations for enhanced CV verification: - FCA Register API: Verify financial services professionals - SRA Register API: Verify solicitors and legal professionals - GitHub API: Verify developer profiles and technical skills - OpenCorporates API: Verify international companies across jurisdictions - ORCID API: Verify academic researchers and publications Includes: - API clients for all five services with retry policies - Service implementations with name matching and validation - Models for verification results with detailed flags - Configuration options in appsettings.json - DI registration for all services 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
266 lines
7.3 KiB
C#
266 lines
7.3 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace RealCV.Infrastructure.Clients;
|
|
|
|
public sealed class GitHubApiClient
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<GitHubApiClient> _logger;
|
|
|
|
public GitHubApiClient(
|
|
HttpClient httpClient,
|
|
IOptions<GitHubOptions> options,
|
|
ILogger<GitHubApiClient> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_logger = logger;
|
|
|
|
_httpClient.BaseAddress = new Uri("https://api.github.com/");
|
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
|
_httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("RealCV/1.0");
|
|
|
|
if (!string.IsNullOrEmpty(options.Value.PersonalAccessToken))
|
|
{
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new AuthenticationHeaderValue("Bearer", options.Value.PersonalAccessToken);
|
|
}
|
|
}
|
|
|
|
public async Task<GitHubUser?> GetUserAsync(string username)
|
|
{
|
|
try
|
|
{
|
|
var url = $"users/{Uri.EscapeDataString(username)}";
|
|
|
|
_logger.LogDebug("Getting GitHub user: {Username}", username);
|
|
|
|
var response = await _httpClient.GetAsync(url);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("GitHub API returned {StatusCode} for user: {Username}",
|
|
response.StatusCode, username);
|
|
return null;
|
|
}
|
|
|
|
return await response.Content.ReadFromJsonAsync<GitHubUser>(JsonOptions);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting GitHub user: {Username}", username);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<List<GitHubRepo>> GetUserReposAsync(string username, int perPage = 100)
|
|
{
|
|
var repos = new List<GitHubRepo>();
|
|
var page = 1;
|
|
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
var url = $"users/{Uri.EscapeDataString(username)}/repos?per_page={perPage}&page={page}&sort=updated";
|
|
|
|
var response = await _httpClient.GetAsync(url);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var pageRepos = await response.Content.ReadFromJsonAsync<List<GitHubRepo>>(JsonOptions);
|
|
|
|
if (pageRepos == null || pageRepos.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
repos.AddRange(pageRepos);
|
|
|
|
if (pageRepos.Count < perPage)
|
|
{
|
|
break;
|
|
}
|
|
|
|
page++;
|
|
|
|
// Limit to avoid rate limiting
|
|
if (page > 5)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting repos for user: {Username}", username);
|
|
}
|
|
|
|
return repos;
|
|
}
|
|
|
|
public async Task<Dictionary<string, int>?> GetRepoLanguagesAsync(string owner, string repo)
|
|
{
|
|
try
|
|
{
|
|
var url = $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/languages";
|
|
|
|
var response = await _httpClient.GetAsync(url);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return await response.Content.ReadFromJsonAsync<Dictionary<string, int>>(JsonOptions);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting languages for repo: {Owner}/{Repo}", owner, repo);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<GitHubUserSearchResponse?> SearchUsersAsync(string query, int perPage = 30)
|
|
{
|
|
try
|
|
{
|
|
var encodedQuery = Uri.EscapeDataString(query);
|
|
var url = $"search/users?q={encodedQuery}&per_page={perPage}";
|
|
|
|
var response = await _httpClient.GetAsync(url);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return await response.Content.ReadFromJsonAsync<GitHubUserSearchResponse>(JsonOptions);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error searching GitHub users: {Query}", query);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
}
|
|
|
|
public class GitHubOptions
|
|
{
|
|
public string PersonalAccessToken { get; set; } = string.Empty;
|
|
}
|
|
|
|
// Response models
|
|
public class GitHubUser
|
|
{
|
|
public string? Login { get; set; }
|
|
public int Id { get; set; }
|
|
public string? Name { get; set; }
|
|
public string? Company { get; set; }
|
|
public string? Blog { get; set; }
|
|
public string? Location { get; set; }
|
|
public string? Email { get; set; }
|
|
public string? Bio { get; set; }
|
|
|
|
[JsonPropertyName("twitter_username")]
|
|
public string? TwitterUsername { get; set; }
|
|
|
|
[JsonPropertyName("public_repos")]
|
|
public int PublicRepos { get; set; }
|
|
|
|
[JsonPropertyName("public_gists")]
|
|
public int PublicGists { get; set; }
|
|
|
|
public int Followers { get; set; }
|
|
public int Following { get; set; }
|
|
|
|
[JsonPropertyName("created_at")]
|
|
public DateTime CreatedAt { get; set; }
|
|
|
|
[JsonPropertyName("updated_at")]
|
|
public DateTime UpdatedAt { get; set; }
|
|
|
|
[JsonPropertyName("html_url")]
|
|
public string? HtmlUrl { get; set; }
|
|
|
|
[JsonPropertyName("avatar_url")]
|
|
public string? AvatarUrl { get; set; }
|
|
}
|
|
|
|
public class GitHubRepo
|
|
{
|
|
public int Id { get; set; }
|
|
public string? Name { get; set; }
|
|
|
|
[JsonPropertyName("full_name")]
|
|
public string? FullName { get; set; }
|
|
|
|
public string? Description { get; set; }
|
|
public string? Language { get; set; }
|
|
|
|
[JsonPropertyName("html_url")]
|
|
public string? HtmlUrl { get; set; }
|
|
|
|
public bool Fork { get; set; }
|
|
public bool Private { get; set; }
|
|
|
|
[JsonPropertyName("stargazers_count")]
|
|
public int StargazersCount { get; set; }
|
|
|
|
[JsonPropertyName("watchers_count")]
|
|
public int WatchersCount { get; set; }
|
|
|
|
[JsonPropertyName("forks_count")]
|
|
public int ForksCount { get; set; }
|
|
|
|
public int Size { get; set; }
|
|
|
|
[JsonPropertyName("created_at")]
|
|
public DateTime CreatedAt { get; set; }
|
|
|
|
[JsonPropertyName("updated_at")]
|
|
public DateTime UpdatedAt { get; set; }
|
|
|
|
[JsonPropertyName("pushed_at")]
|
|
public DateTime? PushedAt { get; set; }
|
|
}
|
|
|
|
public class GitHubUserSearchResponse
|
|
{
|
|
[JsonPropertyName("total_count")]
|
|
public int TotalCount { get; set; }
|
|
|
|
[JsonPropertyName("incomplete_results")]
|
|
public bool IncompleteResults { get; set; }
|
|
|
|
public List<GitHubUserSearchItem>? Items { get; set; }
|
|
}
|
|
|
|
public class GitHubUserSearchItem
|
|
{
|
|
public string? Login { get; set; }
|
|
public int Id { get; set; }
|
|
|
|
[JsonPropertyName("avatar_url")]
|
|
public string? AvatarUrl { get; set; }
|
|
|
|
[JsonPropertyName("html_url")]
|
|
public string? HtmlUrl { get; set; }
|
|
|
|
public double Score { get; set; }
|
|
}
|