//! A GitHub-schema-compatible GraphQL API for browsing owners and repo //! contents, queried by the separate Nuxt.js app (`web/`). //! //! The owner side follows GitHub's schema: `repository(owner:, name:)`, //! `repositoryOwner(login:)` / `user(login:)` / `organization(login:)` //! returning the `RepositoryOwner` interface, `resource(url:)` returning //! the `UniformResourceLocatable` interface, and owners' `repositories` //! as a connection (`nodes`, `totalCount`; `first` is accepted but there's //! no cursor paging). A GitHub client works unchanged against single-level //! owners. //! //! Nested orgs are the one extension. A sub-org's `login` is its full //! path (`acme/platform`), so `repository(owner: "acme/platform", name: //! "api")` and `resource(url: "/acme/platform/api")` just work, and //! `Organization` gains two fields GitHub doesn't have: `parentOrganization` //! and `subOrganizations`. The top-level `repositories` list (the `/repos` //! page's source) is also nugitea-only. //! //! Contents follow GitHub's `repository.object(expression: "REF:PATH")`, //! returning a `GitObject` interface resolved with inline fragments (`... //! on Blob`, `... on Tree`) — that maps almost exactly onto the //! `git_ref:path` addressing `tree::list_tree`/`read_blob` already use on //! the storage tier. `files(ref:)` (a full recursive file list) isn't //! literal GitHub API shape; it's kept because that's what the search //! feature needs. //! //! Each query loads one snapshot of the accounts directory up front, so //! everything in a response is consistent with itself. Anything //! `Authorizer::allow_pull` rejects resolves to `null` or is left out of //! lists, matching how GitHub returns `null` for a repo you can't see //! rather than a different error shape. 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; /// The schema as SDL, for `nugitea graphql-schema` — the source of the /// frontend's checked-in `web/schema.graphql`, which editor GraphQL /// tooling reads to autocomplete and validate queries. 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()) } /// Splits a GitHub-style `"ref"` or `"ref:path"` expression. fn split_expression(expression: &str) -> (&str, &str) { match expression.split_once(':') { Some((r, p)) => (r, p), None => (expression, ""), } } /// Reduces a GitHub `resource(url:)` argument — a full URL or just a path /// — to its path segments. 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() } /// One query's view of the accounts directory, shared by every object it /// returns. struct Snapshot { dir: Directory, auth: Arc, } impl Snapshot { async fn load(ctx: &Context<'_>) -> Result> { let state = ctx.data::()?; let dir = state.accounts.load().await.map_err(gql_err)?; Ok(Arc::new(Snapshot { dir, auth: state.auth.clone() })) } fn repository(self: &Arc, id: Id) -> Option { 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, id: Id) -> Option { 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, repos: impl Iterator, first: Option) -> RepositoryConnection { let mut nodes: Vec = 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(mut nodes: Vec, first: Option, make: impl FnOnce(Vec, 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> { 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> { 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> { Ok(match self.repository_owner(ctx, login).await? { Some(RepositoryOwner::User(u)) => Some(u), _ => None, }) } async fn organization(&self, ctx: &Context<'_>, login: String) -> Result> { Ok(match self.repository_owner(ctx, login).await? { Some(RepositoryOwner::Organization(o)) => Some(o), _ => None, }) } /// Resolves a URL or path to the user, org, or repo it names. Walks /// owner segments until one names a repo, then stops — so trailing /// segments (`/tree/main/src`) still resolve to the repo, and the /// caller can compare `resourcePath` to see what's left over. /// Unambiguous because a sub-org and a repo can't share a name /// within the same parent. async fn resource(&self, ctx: &Context<'_>, url: String) -> Result> { 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), })) } /// Every repo, across all owners (nugitea extension). async fn repositories(&self, ctx: &Context<'_>, first: Option) -> Result { let snap = Snapshot::load(ctx).await?; Ok(snap.repositories(snap.dir.repos().map(|r| r.id), first)) } } // clippy reads the macro's repeated `ty = "String"` as a duplicated // attribute; they're different fields. #[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")) )] 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, total_count: i32, } #[derive(SimpleObject)] pub struct OrganizationConnection { nodes: Vec, total_count: i32, } #[derive(Enum, Copy, Clone, PartialEq, Eq)] pub enum RepositoryVisibility { Public, Internal, Private, } impl From 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, 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) -> RepositoryConnection { self.snap.repositories(self.snap.dir.repos_owned_by(self.id).map(|r| r.id), first) } } pub struct Organization { snap: Arc, id: Id, } #[Object] impl Organization { async fn id(&self) -> ID { ID(format!("O_{}", self.id)) } /// The org's full path — just its slug for a top-level org, like /// GitHub; `parent/child` for a sub-org. 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) -> RepositoryConnection { self.snap.repositories(self.snap.dir.repos_owned_by(self.id).map(|r| r.id), first) } /// nugitea extension: the org this one is nested under, if any. async fn parent_organization(&self) -> Option { let parent = self.snap.dir.account(self.id)?.parent()?; Some(Organization { snap: self.snap.clone(), id: parent }) } /// nugitea extension: orgs nested directly under this one. async fn sub_organizations(&self, first: Option) -> 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, id: Id, /// Canonical `owner[/sub-org...]/name`, which is also the storage /// tier's name for it. 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 { 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 } /// `main` if it exists, else `master`, else whichever branch sorts /// first, else `null` — since HEAD in a freshly created bare repo /// doesn't reliably reflect what was actually pushed. async fn default_branch_ref(&self, ctx: &Context<'_>) -> Result> { let state = ctx.data::()?; 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> { let state = ctx.data::()?; Ok(state.storage.branches(&self.path).await.map_err(gql_err)?.into_iter().map(|name| Ref { name }).collect()) } /// Full recursive file list for the search feature — see the module /// doc comment for why this isn't literal GitHub API shape. async fn files(&self, ctx: &Context<'_>, r#ref: String) -> Result> { let state = ctx.data::()?; state.storage.all_files(&self.path, &r#ref).await.map_err(gql_err) } /// Resolves a `"ref"` or `"ref:path"` expression to a `Tree` or /// `Blob`. Tried as a tree first (a no-op-cheap `git ls-tree`); if /// that fails on a non-empty path, it's tried as a blob instead — a /// path can't be both, so git itself is the source of truth on which /// one an expression resolves to, rather than nugitea guessing from /// the shape of the path string. async fn object(&self, ctx: &Context<'_>, expression: String) -> Result> { let state = ctx.data::()?; 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(); // Not wired to a real `git rev-parse` — nothing here consumes // a tree's own oid (only its entries' oids, which are real), // so it's left blank rather than adding a subprocess call and // a new storage-tier endpoint for a value nothing reads yet. return Ok(Some(GitObject::Tree(Tree { oid: String::new(), entries }))); } if path.is_empty() { return Ok(None); // root of a nonexistent ref is neither a tree nor a blob } 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, } #[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, byte_size: i32, } #[cfg(test)] mod tests { use super::*; use crate::accounts; use crate::auth::AllowAll; use crate::storage_client::StorageClient; /// A schema over a real accounts.json in a temp dir. The storage tier /// isn't running, so only owner-side fields are queryable. 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::())); 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}"), } } } }