use std::sync::Arc;
use async_graphql::{
http::GraphiQLSource, Context, EmptyMutation, EmptySubscription, Enum, Error, Interface, Object, Result, Schema,
SimpleObject, ID,
};
use async_graphql_axum::GraphQL;
use axum::{
response::{Html, IntoResponse},
routing::post_service,
Router,
};
use tower_http::cors::CorsLayer;
use crate::accounts::{Directory, Id, Visibility};
use crate::auth::Authorizer;
use crate::httpgit::AppState;
use crate::tree::EntryKind;
type NugiteaSchema = Schema<Query, EmptyMutation, EmptySubscription>;
pub fn sdl() -> String {
Schema::build(Query, EmptyMutation, EmptySubscription).finish().sdl()
}
pub fn schema(state: AppState) -> NugiteaSchema {
Schema::build(Query, EmptyMutation, EmptySubscription).data(state).finish()
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/graphql", post_service(GraphQL::new(schema(state))).get(graphiql))
.layer(CorsLayer::permissive())
}
async fn graphiql() -> impl IntoResponse {
Html(GraphiQLSource::build().endpoint("/graphql").finish())
}
fn gql_err(e: impl std::fmt::Display) -> Error {
Error::new(e.to_string())
}
fn split_expression(expression: &str) -> (&str, &str) {
match expression.split_once(':') {
Some((r, p)) => (r, p),
None => (expression, ""),
}
}
fn url_segments(url: &str) -> Vec<&str> {
let path = match url.split_once("://") {
Some((_, rest)) => rest.find('/').map_or("", |i| &rest[i..]),
None => url,
};
let path = path.split(['?', '#']).next().unwrap_or("");
path.split('/').filter(|s| !s.is_empty()).collect()
}
struct Snapshot {
dir: Directory,
auth: Arc<dyn Authorizer>,
}
impl Snapshot {
async fn load(ctx: &Context<'_>) -> Result<Arc<Snapshot>> {
let state = ctx.data::<AppState>()?;
let dir = state.accounts.load().await.map_err(gql_err)?;
Ok(Arc::new(Snapshot { dir, auth: state.auth.clone() }))
}
fn repository(self: &Arc<Self>, id: Id) -> Option<Repository> {
let path = self.dir.repo_path(id)?;
if !self.auth.allow_pull(&path) {
return None;
}
Some(Repository { snap: self.clone(), id, path })
}
fn owner(self: &Arc<Self>, id: Id) -> Option<RepositoryOwner> {
let a = self.dir.account(id)?;
let snap = self.clone();
Some(if a.is_org() {
RepositoryOwner::Organization(Organization { snap, id })
} else {
RepositoryOwner::User(User { snap, id })
})
}
fn repositories(self: &Arc<Self>, repos: impl Iterator<Item = Id>, first: Option<i32>) -> RepositoryConnection {
let mut nodes: Vec<Repository> = repos.filter_map(|id| self.repository(id)).collect();
nodes.sort_by(|a, b| a.path.cmp(&b.path));
connection(nodes, first, |nodes, total_count| RepositoryConnection { nodes, total_count })
}
}
fn connection<T, C>(mut nodes: Vec<T>, first: Option<i32>, make: impl FnOnce(Vec<T>, i32) -> C) -> C {
let total = nodes.len() as i32;
if let Some(n) = first {
nodes.truncate(n.max(0) as usize);
}
make(nodes, total)
}
pub struct Query;
#[Object]
impl Query {
async fn repository(&self, ctx: &Context<'_>, owner: String, name: String) -> Result<Option<Repository>> {
let snap = Snapshot::load(ctx).await?;
Ok(snap.dir.resolve_repo(&format!("{owner}/{name}")).and_then(|id| snap.repository(id)))
}
async fn repository_owner(&self, ctx: &Context<'_>, login: String) -> Result<Option<RepositoryOwner>> {
let snap = Snapshot::load(ctx).await?;
Ok(snap.dir.resolve(&login).and_then(|id| snap.owner(id)))
}
async fn user(&self, ctx: &Context<'_>, login: String) -> Result<Option<User>> {
Ok(match self.repository_owner(ctx, login).await? {
Some(RepositoryOwner::User(u)) => Some(u),
_ => None,
})
}
async fn organization(&self, ctx: &Context<'_>, login: String) -> Result<Option<Organization>> {
Ok(match self.repository_owner(ctx, login).await? {
Some(RepositoryOwner::Organization(o)) => Some(o),
_ => None,
})
}
async fn resource(&self, ctx: &Context<'_>, url: String) -> Result<Option<UniformResourceLocatable>> {
let snap = Snapshot::load(ctx).await?;
let segs = url_segments(&url);
if segs.is_empty() {
return Ok(None);
}
for i in 0..segs.len() {
let prefix = segs[..=i].join("/");
if snap.dir.resolve(&prefix).is_some() {
continue;
}
return Ok(snap
.dir
.resolve_repo(&prefix)
.and_then(|id| snap.repository(id))
.map(UniformResourceLocatable::Repository));
}
let owner = snap.dir.resolve(&segs.join("/")).and_then(|id| snap.owner(id));
Ok(owner.map(|o| match o {
RepositoryOwner::User(u) => UniformResourceLocatable::User(u),
RepositoryOwner::Organization(o) => UniformResourceLocatable::Organization(o),
}))
}
async fn repositories(&self, ctx: &Context<'_>, first: Option<i32>) -> Result<RepositoryConnection> {
let snap = Snapshot::load(ctx).await?;
Ok(snap.repositories(snap.dir.repos().map(|r| r.id), first))
}
}
#[allow(clippy::duplicated_attributes)]
#[derive(Interface)]
#[graphql(
field(name = "id", ty = "ID"),
field(name = "login", ty = "String"),
field(name = "resource_path", ty = "String"),
field(name = "repositories", ty = "RepositoryConnection", arg(name = "first", ty = "Option<i32>"))
)]
pub enum RepositoryOwner {
User(User),
Organization(Organization),
}
#[derive(Interface)]
#[graphql(field(name = "resource_path", ty = "String"))]
pub enum UniformResourceLocatable {
Repository(Repository),
User(User),
Organization(Organization),
}
#[derive(SimpleObject)]
pub struct RepositoryConnection {
nodes: Vec<Repository>,
total_count: i32,
}
#[derive(SimpleObject)]
pub struct OrganizationConnection {
nodes: Vec<Organization>,
total_count: i32,
}
#[derive(Enum, Copy, Clone, PartialEq, Eq)]
pub enum RepositoryVisibility {
Public,
Internal,
Private,
}
impl From<Visibility> for RepositoryVisibility {
fn from(v: Visibility) -> Self {
match v {
Visibility::Public => RepositoryVisibility::Public,
Visibility::Internal => RepositoryVisibility::Internal,
Visibility::Private => RepositoryVisibility::Private,
}
}
}
pub struct User {
snap: Arc<Snapshot>,
id: Id,
}
#[Object]
impl User {
async fn id(&self) -> ID {
ID(format!("U_{}", self.id))
}
async fn login(&self) -> String {
self.snap.dir.path(self.id).unwrap_or_default()
}
async fn resource_path(&self) -> String {
format!("/{}", self.snap.dir.path(self.id).unwrap_or_default())
}
async fn repositories(&self, first: Option<i32>) -> RepositoryConnection {
self.snap.repositories(self.snap.dir.repos_owned_by(self.id).map(|r| r.id), first)
}
}
pub struct Organization {
snap: Arc<Snapshot>,
id: Id,
}
#[Object]
impl Organization {
async fn id(&self) -> ID {
ID(format!("O_{}", self.id))
}
async fn login(&self) -> String {
self.snap.dir.path(self.id).unwrap_or_default()
}
async fn resource_path(&self) -> String {
format!("/{}", self.snap.dir.path(self.id).unwrap_or_default())
}
async fn repositories(&self, first: Option<i32>) -> RepositoryConnection {
self.snap.repositories(self.snap.dir.repos_owned_by(self.id).map(|r| r.id), first)
}
async fn parent_organization(&self) -> Option<Organization> {
let parent = self.snap.dir.account(self.id)?.parent()?;
Some(Organization { snap: self.snap.clone(), id: parent })
}
async fn sub_organizations(&self, first: Option<i32>) -> OrganizationConnection {
let mut orgs: Vec<_> = self.snap.dir.child_orgs(self.id).map(|a| (a.slug.clone(), a.id)).collect();
orgs.sort();
let nodes = orgs.into_iter().map(|(_, id)| Organization { snap: self.snap.clone(), id }).collect();
connection(nodes, first, |nodes, total_count| OrganizationConnection { nodes, total_count })
}
}
pub struct Repository {
snap: Arc<Snapshot>,
id: Id,
path: String,
}
impl Repository {
fn visibility_value(&self) -> Visibility {
self.snap.dir.repo(self.id).map_or(Visibility::Private, |r| r.visibility)
}
}
#[Object]
impl Repository {
async fn id(&self) -> ID {
ID(format!("R_{}", self.id))
}
async fn name(&self) -> &str {
self.path.rsplit_once('/').map_or(&self.path, |(_, name)| name)
}
async fn name_with_owner(&self) -> &str {
&self.path
}
async fn owner(&self) -> Result<RepositoryOwner> {
let owner = self.snap.dir.repo(self.id).map(|r| r.owner);
owner.and_then(|o| self.snap.owner(o)).ok_or_else(|| gql_err("repository has no owner"))
}
async fn resource_path(&self) -> String {
format!("/{}", self.path)
}
async fn visibility(&self) -> RepositoryVisibility {
self.visibility_value().into()
}
async fn is_private(&self) -> bool {
self.visibility_value() == Visibility::Private
}
async fn default_branch_ref(&self, ctx: &Context<'_>) -> Result<Option<Ref>> {
let state = ctx.data::<AppState>()?;
let branches = state.storage.branches(&self.path).await.map_err(gql_err)?;
let default = branches
.iter()
.find(|b| b.as_str() == "main")
.or_else(|| branches.iter().find(|b| b.as_str() == "master"))
.or_else(|| branches.iter().min())
.cloned();
Ok(default.map(|name| Ref { name }))
}
async fn refs(&self, ctx: &Context<'_>) -> Result<Vec<Ref>> {
let state = ctx.data::<AppState>()?;
Ok(state.storage.branches(&self.path).await.map_err(gql_err)?.into_iter().map(|name| Ref { name }).collect())
}
async fn files(&self, ctx: &Context<'_>, r#ref: String) -> Result<Vec<String>> {
let state = ctx.data::<AppState>()?;
state.storage.all_files(&self.path, &r#ref).await.map_err(gql_err)
}
async fn object(&self, ctx: &Context<'_>, expression: String) -> Result<Option<GitObject>> {
let state = ctx.data::<AppState>()?;
let (git_ref, path) = split_expression(&expression);
if let Ok(entries) = state.storage.tree(&self.path, git_ref, path).await {
let entries = entries
.into_iter()
.map(|e| TreeEntry {
name: e.name,
r#type: match e.kind {
EntryKind::Tree => "tree".to_string(),
EntryKind::Blob => "blob".to_string(),
},
oid: e.sha,
})
.collect();
return Ok(Some(GitObject::Tree(Tree { oid: String::new(), entries })));
}
if path.is_empty() {
return Ok(None);
}
match state.storage.blob(&self.path, git_ref, path).await {
Ok(blob) => Ok(Some(GitObject::Blob(Blob {
oid: blob.sha,
is_binary: blob.binary,
byte_size: blob.content.len() as i32,
text: if blob.binary { None } else { Some(blob.content) },
}))),
Err(_) => Ok(None),
}
}
}
#[derive(SimpleObject)]
pub struct Ref {
name: String,
}
#[derive(Interface)]
#[graphql(field(name = "oid", ty = "String"))]
pub enum GitObject {
Tree(Tree),
Blob(Blob),
}
#[derive(SimpleObject)]
pub struct Tree {
oid: String,
entries: Vec<TreeEntry>,
}
#[derive(SimpleObject)]
pub struct TreeEntry {
name: String,
#[graphql(name = "type")]
r#type: String,
oid: String,
}
#[derive(SimpleObject)]
pub struct Blob {
oid: String,
is_binary: bool,
text: Option<String>,
byte_size: i32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accounts;
use crate::auth::AllowAll;
use crate::storage_client::StorageClient;
async fn schema_with(setup: impl FnOnce(&mut Directory) -> anyhow::Result<()>) -> NugiteaSchema {
let dir =
std::env::temp_dir().join(format!("nugitea-graphql-test-{}-{}", std::process::id(), rand::random::<u64>()));
let accounts = Arc::new(accounts::Store::new(&dir).unwrap());
accounts.update(setup).await.unwrap();
schema(AppState {
storage: Arc::new(StorageClient::new("http://127.0.0.1:1".into(), "127.0.0.1:1".into())),
auth: Arc::new(AllowAll),
accounts,
})
}
async fn run(schema: &NugiteaSchema, query: &str) -> serde_json::Value {
let resp = schema.execute(query).await;
assert!(resp.errors.is_empty(), "{:?}", resp.errors);
serde_json::to_value(resp.data).unwrap()
}
async fn fixture() -> NugiteaSchema {
schema_with(|d| {
d.add_user("alice")?;
d.add_repo_at("alice/dotfiles", Visibility::Private)?;
d.add_org_at("acme", Visibility::Public)?;
d.add_org_at("acme/platform", Visibility::Public)?;
d.add_repo_at("acme/site", Visibility::Public)?;
d.add_repo_at("acme/platform/api", Visibility::Internal)?;
Ok(())
})
.await
}
#[tokio::test]
async fn repository_by_owner_and_name() {
let s = fixture().await;
let v = run(
&s,
r#"{ repository(owner: "ACME/platform", name: "api") {
name nameWithOwner resourcePath visibility isPrivate
owner { __typename login ... on Organization { parentOrganization { login } } }
} }"#,
)
.await;
assert_eq!(
v["repository"],
serde_json::json!({
"name": "api", "nameWithOwner": "acme/platform/api", "resourcePath": "/acme/platform/api",
"visibility": "INTERNAL", "isPrivate": false,
"owner": { "__typename": "Organization", "login": "acme/platform", "parentOrganization": { "login": "acme" } }
})
);
let v = run(&s, r#"{ repository(owner: "acme", name: "api") { name } }"#).await;
assert!(v["repository"].is_null());
}
#[tokio::test]
async fn owners_and_connections() {
let s = fixture().await;
let v = run(
&s,
r#"{
user(login: "alice") { login repositories(first: 10) { totalCount nodes { name } } }
notAnOrg: organization(login: "alice") { login }
organization(login: "acme") {
repositories { nodes { nameWithOwner } }
subOrganizations { totalCount nodes { login } }
}
repositoryOwner(login: "acme/platform") { __typename resourcePath }
repositories(first: 2) { totalCount nodes { nameWithOwner } }
}"#,
)
.await;
assert_eq!(
v["user"]["repositories"],
serde_json::json!({ "totalCount": 1, "nodes": [{ "name": "dotfiles" }] })
);
assert!(v["notAnOrg"].is_null());
assert_eq!(v["organization"]["repositories"]["nodes"], serde_json::json!([{ "nameWithOwner": "acme/site" }]));
assert_eq!(v["organization"]["subOrganizations"]["nodes"], serde_json::json!([{ "login": "acme/platform" }]));
assert_eq!(
v["repositoryOwner"],
serde_json::json!({ "__typename": "Organization", "resourcePath": "/acme/platform" })
);
assert_eq!(v["repositories"]["totalCount"], 3);
assert_eq!(v["repositories"]["nodes"].as_array().unwrap().len(), 2);
}
#[test]
fn checked_in_schema_is_current() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/web/schema.graphql");
let checked_in = std::fs::read_to_string(path).unwrap_or_default();
assert!(
checked_in == sdl(),
"web/schema.graphql is stale; regenerate it with `cargo run --bin nugitea -- graphql-schema > web/schema.graphql`"
);
}
#[tokio::test]
async fn resource_by_url() {
let s = fixture().await;
let q = |url: &str| format!(r#"{{ resource(url: "{url}") {{ __typename resourcePath }} }}"#);
let cases = [
("/acme", Some(("Organization", "/acme"))),
("https://example.com/acme/platform", Some(("Organization", "/acme/platform"))),
("/acme/platform/api", Some(("Repository", "/acme/platform/api"))),
("/acme/platform/api/tree/main/src?x=1", Some(("Repository", "/acme/platform/api"))),
("/Alice/dotfiles/blob/main/.vimrc", Some(("Repository", "/alice/dotfiles"))),
("/alice", Some(("User", "/alice"))),
("/acme/nope", None),
("/", None),
];
for (url, want) in cases {
let v = run(&s, &q(url)).await;
match want {
Some((ty, path)) => {
assert_eq!(v["resource"], serde_json::json!({ "__typename": ty, "resourcePath": path }), "{url}")
}
None => assert!(v["resource"].is_null(), "{url}"),
}
}
}
}