feat!: add repo searching, only show most relevant repos first

This commit is contained in:
2026-01-16 23:29:15 -08:00
parent 68d9a0a626
commit 831259a6a6
14 changed files with 174 additions and 59 deletions

1
api/Cargo.lock generated
View File

@@ -2476,6 +2476,7 @@ dependencies = [
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",

View File

@@ -7,7 +7,7 @@ edition = "2024"
actix-web = "4.12.1"
chrono = { version = "0.4.43", features = ["serde"] }
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
reqwest = { version = "0.13.1", features = ["json"] }
reqwest = { version = "0.13.1", features = ["json", "query"] }
sentry = { version = "0.46.1", features = ["actix", "tracing"] }
serde = "1.0.228"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-native-tls"] }

View File

@@ -6,14 +6,18 @@ use jsonwebtoken::{
};
use serde::Deserialize;
pub struct UserId(pub String);
#[derive(Clone)]
pub struct User {
pub id: String,
pub name: String,
}
pub struct JWT {
reqwest_client: reqwest::Client,
}
pub trait AuthImpl {
async fn for_protected(&self, token: &str) -> Result<UserId>;
async fn for_protected(&self, token: &str) -> Result<User>;
}
pub enum Auth {
@@ -23,6 +27,7 @@ pub enum Auth {
#[derive(Deserialize)]
struct Claims {
sub: String,
name: String,
}
impl JWT {
@@ -32,7 +37,7 @@ impl JWT {
}
impl AuthImpl for JWT {
async fn for_protected(&self, token: &str) -> Result<UserId> {
async fn for_protected(&self, token: &str) -> Result<User> {
let frontend_url =
env::var("FRONTEND_BASE_URL").unwrap_or_else(|_| "http://localhost:5173".to_string());
@@ -57,12 +62,15 @@ impl AuthImpl for JWT {
_ => crate::error::Error::Unauthorized,
})?;
Ok(UserId(token_data.claims.sub))
Ok(User {
id: token_data.claims.sub,
name: token_data.claims.name,
})
}
}
impl AuthImpl for Auth {
async fn for_protected(&self, token: &str) -> Result<UserId> {
async fn for_protected(&self, token: &str) -> Result<User> {
match self {
Auth::JWT(jwt) => jwt.for_protected(token).await,
}

View File

@@ -2,38 +2,42 @@ use actix_web::{HttpRequest, HttpResponse, web};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{AppState, account::AccountRepository, error::Result};
use crate::{AppState, account::AccountRepository, auth::User, error::Result};
#[derive(Debug, Serialize, Deserialize)]
struct Repository {
id: u64,
name: String,
full_name: String,
description: Option<String>,
language: Option<String>,
pub struct Repository {
pub id: u64,
pub name: String,
pub full_name: String,
pub description: Option<String>,
pub language: Option<String>,
#[serde(alias = "stargazers_count")]
stars: Option<usize>,
updated_at: DateTime<Utc>,
private: bool,
pub stars: Option<usize>,
pub updated_at: DateTime<Utc>,
pub private: bool,
}
pub async fn get_repos(
app_state: web::Data<AppState>,
path: web::Path<String>,
req: web::ReqData<User>,
) -> Result<HttpResponse> {
let user_id = path.into_inner();
let user = req.into_inner();
let query = AccountRepository::new(&app_state.pool);
let token = query.get_access_token(&user_id).await?;
let token = query.get_access_token(&user.id).await?;
let response = app_state
.reqwest_client
.get("https://api.github.com/user/repos?affiliation=owner")
.get("https://api.github.com/user/repos?affiliation=owner&sort=updated&direction=desc&per_page=5")
.bearer_auth(token)
.send()
.await?;
response.error_for_status_ref()?;
let data = response.json::<Vec<Repository>>().await?;
tracing::debug!(github_response = ?data.iter().filter(|r| r.private == true).collect::<Vec<&Repository>>(), "received repos");
let data = response
.json::<Vec<Repository>>()
.await?
.into_iter()
.filter(|r| r.private == false)
.collect::<Vec<Repository>>();
Ok(HttpResponse::Ok().json(data))
}

View File

@@ -1 +1,2 @@
pub mod get_repos;
pub mod search_repos;

View File

@@ -0,0 +1,50 @@
use crate::{
account::AccountRepository, auth::User, endpoints::get_repos::Repository, error::Result,
};
use actix_web::{
HttpRequest, HttpResponse,
web::{self, ReqData},
};
use serde::{Deserialize, Serialize};
use crate::AppState;
#[derive(Deserialize)]
pub(crate) struct SearchQuery {
q: String,
}
#[derive(Serialize, Deserialize)]
struct SearchResponse {
items: Vec<Repository>,
}
pub async fn search_repos(
app_state: web::Data<AppState>,
query: web::Query<SearchQuery>,
req: ReqData<User>,
) -> Result<HttpResponse> {
let user = req.into_inner();
let token = AccountRepository::new(&app_state.pool)
.get_access_token(&user.id)
.await?;
let search_query = format!("user:{} {} fork:true", user.name, query.q);
let response = app_state
.reqwest_client
.get("https://api.github.com/search/repositories")
.query(&[("q", &search_query)])
.bearer_auth(&token)
.send()
.await?
.json::<SearchResponse>()
.await?;
Ok(HttpResponse::Ok().json(
response
.items
.into_iter()
.filter(|r| r.private == false)
.collect::<Vec<Repository>>(),
))
}

View File

@@ -6,7 +6,12 @@ mod middleware;
use std::env;
use actix_web::{App, HttpServer, middleware::from_fn, rt::System, web};
use actix_web::{
App, HttpServer,
middleware::from_fn,
rt::System,
web::{self, route},
};
use sqlx::PgPool;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::{
@@ -59,9 +64,11 @@ async fn run() -> std::io::Result<()> {
web::scope("/api").service(
web::scope("/v0").service(
web::scope("/user")
.route("/repos", web::get().to(endpoints::get_repos::get_repos))
.wrap(from_fn(middleware::protected))
.route(
"/{user_id}/repos",
web::get().to(endpoints::get_repos::get_repos),
"/repos/search",
web::get().to(endpoints::search_repos::search_repos),
)
.wrap(from_fn(middleware::protected)),
),

View File

@@ -20,8 +20,8 @@ pub async fn protected(
.map_into_right_body());
};
let user_id = app_state.auth.for_protected(&token).await?;
req.extensions_mut().insert(user_id);
let user = app_state.auth.for_protected(&token).await?;
req.extensions_mut().insert(user);
next.call(req)
.await