Skip to content

Commit 8c5d069

Browse files
committed
Add backend API
1 parent e742c4f commit 8c5d069

9 files changed

Lines changed: 2086 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1747 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[package]
2+
name = "gulb-backend"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
tokio = { version = "1", features = ["macros"] }
8+
tracing = { version = "0.1" }
9+
tracing-subscriber = { version = "0.3", default-features = false, features = [
10+
"fmt",
11+
] }
12+
serde = { version = "1.0.209", features = ["derive"] }
13+
serde_json = { version = "1.0.127", features = ["raw_value"] }
14+
url = "2.5.2"
15+
vercel_runtime = "1.1.4"
16+
octocrab = { version = "0.39.0", features = ["stream"] }
17+
anyhow = "1.0.88"
18+
secrecy = "0.8.0"
19+
thiserror = "1.0.63"
20+
problem_details = "0.6.0"
21+
http = "1.1.0"
22+
futures = "0.3.30"
23+
24+
[lib]
25+
path = "api/lib/lib.rs"
26+
27+
[[bin]]
28+
name = "user-langs-api"
29+
path = "api/langs.rs"

api/langs.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use gulb_backend::api_utils::missing_query_param;
2+
use gulb_backend::api_utils::{problem_details, ErrorConverter};
3+
use gulb_backend::github_fetch::{get_repos_for_org, get_repos_for_user};
4+
use gulb_backend::langs_calculator::calculate_langs;
5+
use gulb_backend::CRAB;
6+
7+
use problem_details::ProblemDetails;
8+
use serde::Serialize;
9+
use std::collections::HashMap;
10+
use url::Url;
11+
use vercel_runtime::run;
12+
use vercel_runtime::{http::ok, Body, Error, Request, Response};
13+
14+
const NAME_PARAM: &str = "name";
15+
const IS_ORG_PARAM: &str = "isorg";
16+
17+
#[derive(Serialize)]
18+
struct GetUserLangsResponse {
19+
name: String,
20+
langs: HashMap<String, i64>,
21+
}
22+
23+
#[tokio::main]
24+
async fn main() -> Result<(), Error> {
25+
run(handler).await
26+
}
27+
28+
pub async fn handler(req: Request) -> Result<Response<Body>, Error> {
29+
let parsed_url = Url::parse(&req.uri().to_string()).unwrap();
30+
let hash_query: HashMap<String, String> = parsed_url.query_pairs().into_owned().collect();
31+
32+
let Some(name) = hash_query.get(NAME_PARAM) else {
33+
return missing_query_param(NAME_PARAM);
34+
};
35+
36+
let repos = if let Some(_) = hash_query.get(IS_ORG_PARAM) {
37+
get_repos_for_org(&*CRAB, name).await
38+
} else {
39+
get_repos_for_user(&*CRAB, name).await
40+
};
41+
42+
match repos {
43+
Ok(repos) => match calculate_langs(&*CRAB, repos, Option::default()).await {
44+
Ok(langs) => {
45+
let res = GetUserLangsResponse {
46+
name: name.to_string(),
47+
langs,
48+
};
49+
50+
ok(&res)
51+
}
52+
Err(err) => problem_details(&ProblemDetails::from_calc_err(err)),
53+
},
54+
Err(err) => problem_details(&ProblemDetails::from_octocrab_err(err)),
55+
}
56+
}

api/lib/api_utils.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
use std::str::FromStr;
2+
3+
use http::{Response, StatusCode};
4+
5+
pub use problem_details::ProblemDetails;
6+
use serde::Serialize;
7+
use vercel_runtime::Body;
8+
9+
#[derive(serde::Serialize)]
10+
pub struct ErrorsExt<T> {
11+
errors: Vec<T>,
12+
}
13+
14+
impl<T> ErrorsExt<T> {
15+
pub fn new(errors: Vec<T>) -> Self {
16+
Self { errors }
17+
}
18+
}
19+
20+
#[derive(serde::Serialize)]
21+
pub struct MissingParameterDetails {
22+
detail: String,
23+
parameter: String,
24+
}
25+
26+
impl MissingParameterDetails {
27+
pub fn new(parameter_name: impl Into<String>) -> Self {
28+
let parameter_string = parameter_name.into();
29+
30+
MissingParameterDetails {
31+
detail: format!("The query parameter {} is required", parameter_string),
32+
parameter: parameter_string,
33+
}
34+
}
35+
}
36+
37+
pub trait ErrorConverter {
38+
fn from_octocrab_err(err: octocrab::Error) -> Self;
39+
fn from_calc_err(err: crate::langs_calculator::LangCalcError) -> Self;
40+
}
41+
42+
impl ErrorConverter for ProblemDetails {
43+
fn from_octocrab_err(err: octocrab::Error) -> Self {
44+
match err {
45+
octocrab::Error::GitHub {
46+
source,
47+
backtrace: _,
48+
} => problem_details::ProblemDetails::new()
49+
.with_title("GitHub API Error")
50+
.with_status(source.status_code)
51+
.with_detail(source.message),
52+
53+
_ => create_internal_server_err_details(),
54+
}
55+
}
56+
57+
fn from_calc_err(err: crate::langs_calculator::LangCalcError) -> Self {
58+
match err {
59+
crate::langs_calculator::LangCalcError::OctocrabError(err) => {
60+
Self::from_octocrab_err(err)
61+
}
62+
63+
_ => create_internal_server_err_details(),
64+
}
65+
}
66+
}
67+
68+
pub fn missing_query_param(
69+
param_name: impl Into<String>,
70+
) -> Result<Response<Body>, vercel_runtime::Error> {
71+
let errors = vec![MissingParameterDetails::new(param_name)];
72+
73+
let details = ProblemDetails::new()
74+
.with_type(
75+
http::Uri::from_str(
76+
"https://problems-registry.smartbear.com/missing-request-parameter",
77+
)
78+
.unwrap(),
79+
)
80+
.with_title("Missing request parameter")
81+
.with_detail("The request is missing an expected query parameter.")
82+
.with_status(StatusCode::BAD_REQUEST)
83+
.with_extensions(ErrorsExt::new(errors));
84+
85+
ext_problem_details(&details)
86+
}
87+
88+
pub fn create_internal_server_err_details() -> ProblemDetails {
89+
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
90+
.with_detail("An Internal Server Error has occurred")
91+
}
92+
93+
pub fn problem_details(problem: &ProblemDetails) -> Result<Response<Body>, vercel_runtime::Error> {
94+
Ok(Response::builder()
95+
.status(&problem.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
96+
.header("Content-Type", "application/json")
97+
.body(serde_json::to_string(&problem).unwrap().into())?)
98+
}
99+
100+
pub fn ext_problem_details<Ext>(
101+
problem: &ProblemDetails<Ext>,
102+
) -> Result<Response<Body>, vercel_runtime::Error>
103+
where
104+
Ext: Serialize,
105+
{
106+
Ok(Response::builder()
107+
.status(&problem.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
108+
.header("Content-Type", "application/json")
109+
.body(serde_json::to_string(&problem).unwrap().into())?)
110+
}

api/lib/github_auth.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use std::env;
2+
3+
use anyhow::{Context, Result};
4+
use secrecy::SecretString;
5+
6+
const TOKEN_ENV_VAR: &str = "GITHUB_API_TOKEN";
7+
8+
pub fn get_gh_token() -> Result<SecretString> {
9+
let env_var_value = env::var(TOKEN_ENV_VAR)
10+
.with_context(|| format!("Failed to read environment variable {}", TOKEN_ENV_VAR))?;
11+
12+
let secret_string = SecretString::try_from(env_var_value)
13+
.context("Failed to convert token string value into a secret string.")?;
14+
15+
Ok(secret_string)
16+
}

api/lib/github_fetch.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use std::collections::HashMap;
2+
3+
use octocrab::{
4+
models::Repository, params::repos::Type as OrgRepoType,
5+
params::users::repos::Type as UserRepoType, Octocrab, Page,
6+
};
7+
8+
pub type RepoLanguages = HashMap<String, i64>;
9+
10+
pub async fn get_repos_for_user(
11+
crab: &Octocrab,
12+
user: impl Into<String>,
13+
) -> Result<Page<Repository>, octocrab::Error> {
14+
crab.users(user)
15+
.repos()
16+
.r#type(UserRepoType::Owner)
17+
.per_page(100)
18+
.send()
19+
.await
20+
}
21+
22+
pub async fn get_repos_for_org(
23+
crab: &Octocrab,
24+
org_name: impl Into<String>,
25+
) -> Result<Page<Repository>, octocrab::Error> {
26+
crab.orgs(org_name)
27+
.list_repos()
28+
.repo_type(OrgRepoType::Sources)
29+
.per_page(100)
30+
.send()
31+
.await
32+
}
33+
34+
pub async fn get_langs_for_repo(
35+
crab: &Octocrab,
36+
repo_owner: impl Into<String>,
37+
repo_name: impl Into<String>,
38+
) -> Result<RepoLanguages, octocrab::Error> {
39+
crab.repos(repo_owner, repo_name).list_languages().await
40+
}

api/lib/langs_calculator.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use futures::TryStreamExt;
2+
use std::collections::HashMap;
3+
use tokio::pin;
4+
5+
use octocrab::{models::Repository, Octocrab, Page};
6+
7+
use crate::github_fetch;
8+
9+
#[derive(thiserror::Error, Debug)]
10+
pub enum LangCalcError {
11+
#[error("GitHub API error")]
12+
OctocrabError(#[from] octocrab::Error),
13+
#[error("invalid repo object")]
14+
InvalidRepoObject(),
15+
#[error("unknown error")]
16+
Unknown,
17+
}
18+
19+
pub async fn calculate_langs(
20+
crab: &Octocrab,
21+
start_page: Page<Repository>,
22+
max_pages: Option<u8>,
23+
) -> Result<HashMap<String, i64>, LangCalcError> {
24+
let max_repos = max_pages.unwrap_or(5) * 100;
25+
26+
let mut results: HashMap<String, i64> = HashMap::new();
27+
let repo_pages_stream = start_page.into_stream(crab);
28+
29+
pin!(repo_pages_stream);
30+
31+
let mut current_repo = 0;
32+
33+
while let Some(repo) = repo_pages_stream.try_next().await? {
34+
if current_repo > max_repos {
35+
break;
36+
}
37+
38+
if let Some(true) = repo.fork {
39+
continue;
40+
}
41+
42+
let Some(repo_owner) = repo.owner.map(|x| x.login) else {
43+
continue;
44+
};
45+
46+
let repo_langs = github_fetch::get_langs_for_repo(crab, repo_owner, repo.name).await?;
47+
48+
for (lang, bytes) in repo_langs {
49+
results
50+
.entry(lang)
51+
.and_modify(|count| *count += bytes)
52+
.or_insert(bytes);
53+
}
54+
55+
current_repo += 1;
56+
}
57+
58+
Ok(results)
59+
}

api/lib/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use octocrab::Octocrab;
2+
3+
use std::sync::LazyLock;
4+
5+
pub mod api_utils;
6+
pub mod github_auth;
7+
pub mod github_fetch;
8+
pub mod langs_calculator;
9+
10+
pub static CRAB: LazyLock<Octocrab> = LazyLock::new(|| {
11+
Octocrab::builder()
12+
.personal_token(crate::github_auth::get_gh_token().unwrap())
13+
.build()
14+
.unwrap()
15+
});

vercel.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"outputDirectory": "public",
3+
"rewrites": [
4+
{
5+
"source": "/api/langs",
6+
"destination": "/api/langs.rs"
7+
}
8+
],
9+
"functions": {
10+
"api/langs.rs": {
11+
"runtime": "vercel-rust@4.0.8"
12+
}
13+
}
14+
}

0 commit comments

Comments
 (0)