Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
http: Decouple api responses from heartwood crates
Sebastian Martinez committed 1 year ago
commit fc83829ef69ef8c2ccb966365055c0e6727dc818
parent 3d4d76c
7 files changed +640 -304
modified radicle-httpd/src/api.rs
@@ -7,19 +7,15 @@ use axum::http::Method;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::Router;
-
use radicle::identity::doc::PayloadId;
-
use radicle::issue::cache::Issues as _;
-
use radicle::patch::cache::Patches as _;
-
use radicle::storage::git::Repository;
-
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tower_http::cors::{self, CorsLayer};

-
use radicle::cob::{issue, patch, Author};
+
use radicle::identity::doc::PayloadId;
use radicle::identity::{DocAt, RepoId};
-
use radicle::node::policy::Scope;
+
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
-
use radicle::node::{AliasStore, NodeId};
+
use radicle::patch::cache::Patches as _;
+
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::Profile;

@@ -62,7 +58,7 @@ impl Context {
        let delegates = doc
            .delegates
            .into_iter()
-
            .map(|did| json::author(&Author::new(did), aliases.alias(did.as_key())))
+
            .map(|did| json::Author::new(&did).as_json(&aliases))
            .collect::<Vec<_>>();
        let db = &self.profile.database()?;
        let seeding = db.count(&rid).unwrap_or_default();
@@ -70,19 +66,13 @@ impl Context {
        let payloads: BTreeMap<PayloadId, Value> = doc
            .payload
            .into_iter()
-
            .filter_map(|(id, payload)| match id {
-
                id if id == PayloadId::project() => {
-
                    let Ok((_, head)) = repo.head() else {
-
                        return None;
-
                    };
-
                    let (Ok(patches), Ok(issues)) =
-
                        (self.profile.patches(repo), self.profile.issues(repo))
-
                    else {
-
                        return None;
-
                    };
-
                    let (Ok(patches), Ok(issues)) = (patches.counts(), issues.counts()) else {
-
                        return None;
-
                    };
+
            .filter_map(|(id, payload)| {
+
                if id == PayloadId::project() {
+
                    let (_, head) = repo.head().ok()?;
+
                    let patches = self.profile.patches(repo).ok()?;
+
                    let patches = patches.counts().ok()?;
+
                    let issues = self.profile.issues(repo).ok()?;
+
                    let issues = issues.counts().ok()?;

                    Some((
                        id,
@@ -95,8 +85,9 @@ impl Context {
                            }
                        }),
                    ))
+
                } else {
+
                    Some((id, json!({ "data": payload })))
                }
-
                _ => Some((id, json!({ "data": payload }))),
            })
            .collect();

modified radicle-httpd/src/api/json.rs
@@ -1,296 +1,81 @@
-
//! Utilities for building JSON responses of our API.
-

use std::collections::BTreeMap;
-
use std::path::Path;
-
use std::str;

-
use base64::prelude::{Engine, BASE64_STANDARD};
use serde_json::{json, Value};

-
use radicle::cob::issue::{Issue, IssueId};
-
use radicle::cob::patch::{Merge, Patch, PatchId, Review};
-
use radicle::cob::thread::{Comment, CommentId, Edit};
-
use radicle::cob::{ActorId, Author, CodeLocation, Reaction};
-
use radicle::git::RefString;
-
use radicle::node::{Alias, AliasStore};
-
use radicle::prelude::NodeId;
-
use radicle::storage::{git, refs, RemoteRepository};
-
use radicle_surf::blob::Blob;
-
use radicle_surf::tree::{EntryKind, Tree};
-
use radicle_surf::{Commit, Oid};
-

-
/// Returns JSON of a commit.
-
pub(crate) fn commit(commit: &Commit) -> Value {
-
    json!({
-
      "id": commit.id,
-
      "author": {
-
        "name": commit.author.name,
-
        "email": commit.author.email
-
      },
-
      "summary": commit.summary,
-
      "description": commit.description(),
-
      "parents": commit.parents,
-
      "committer": {
-
        "name": commit.committer.name,
-
        "email": commit.committer.email,
-
        "time": commit.committer.time.seconds()
-
      }
-
    })
-
}
-

-
/// Returns JSON for a blob with a given `path`.
-
pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value {
-
    json!({
-
        "binary": blob.is_binary(),
-
        "name": name_in_path(path),
-
        "content": blob_content(blob),
-
        "path": path,
-
        "lastCommit": commit(blob.commit())
-
    })
-
}
-

-
/// Returns a string for the blob content, encoded in base64 if binary.
-
pub fn blob_content<T: AsRef<[u8]>>(blob: &Blob<T>) -> String {
-
    match str::from_utf8(blob.content()) {
-
        Ok(s) => s.to_owned(),
-
        Err(_) => BASE64_STANDARD.encode(blob.content()),
-
    }
-
}
-

-
/// Returns JSON for a tree with a given `path` and `stats`.
-
pub(crate) fn tree(tree: &Tree, path: &str) -> Value {
-
    let prefix = Path::new(path);
-
    let entries = tree
-
        .entries()
-
        .iter()
-
        .map(|entry| {
-
            json!({
-
                "path": prefix.join(entry.name()),
-
                "oid": entry.object_id(),
-
                "name": entry.name(),
-
                "kind": match entry.entry() {
-
                    EntryKind::Tree(_) => "tree",
-
                    EntryKind::Blob(_) => "blob",
-
                    EntryKind::Submodule { .. } => "submodule"
-
                },
-
            })
-
        })
-
        .collect::<Vec<_>>();
-

-
    json!({
-
        "entries": &entries,
-
        "lastCommit": commit(tree.commit()),
-
        "name": name_in_path(path),
-
        "path": path,
-
    })
-
}
-

-
/// Returns JSON for an `issue`.
-
pub(crate) fn issue(id: IssueId, issue: Issue, aliases: &impl AliasStore) -> Value {
-
    json!({
-
        "id": id.to_string(),
-
        "author": author(&issue.author(), aliases.alias(issue.author().id())),
-
        "title": issue.title(),
-
        "state": issue.state(),
-
        "assignees": issue.assignees().map(|assignee|
-
            author(&Author::from(*assignee.as_key()), aliases.alias(assignee))
-
        ).collect::<Vec<_>>(),
-
        "discussion": issue.comments().map(|(id, c)| issue_comment(id, c, aliases)).collect::<Vec<_>>(),
-
        "labels": issue.labels().collect::<Vec<_>>(),
-
    })
-
}
+
use radicle::cob;
+
use radicle::identity;
+
use radicle::node::AliasStore;

-
/// Returns JSON for a `patch`.
-
pub(crate) fn patch(
-
    id: PatchId,
-
    patch: Patch,
-
    repo: &git::Repository,
-
    aliases: &impl AliasStore,
-
) -> Value {
-
    json!({
-
        "id": id.to_string(),
-
        "author": author(patch.author(), aliases.alias(patch.author().id())),
-
        "title": patch.title(),
-
        "state": patch.state(),
-
        "target": patch.target(),
-
        "labels": patch.labels().collect::<Vec<_>>(),
-
        "merges": patch.merges().map(|(nid, m)| merge(nid, m, aliases)).collect::<Vec<_>>(),
-
        "assignees": patch.assignees().map(|assignee|
-
            author(&Author::from(*assignee), aliases.alias(&assignee))
-
        ).collect::<Vec<_>>(),
-
        "revisions": patch.revisions().map(|(id, rev)| {
-
            json!({
-
                "id": id,
-
                "author": author(rev.author(), aliases.alias(rev.author().id())),
-
                "description": rev.description(),
-
                "edits": rev.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
-
                "reactions": rev.reactions().iter().flat_map(|(location, reaction)| {
-
                    reactions(reaction.iter().fold(BTreeMap::new(), |mut acc: BTreeMap<&Reaction, Vec<_>>, (author, emoji)| {
-
                        acc.entry(emoji).or_default().push(author);
-
                        acc
-
                    }), location.as_ref(), aliases)
-
                }).collect::<Vec<_>>(),
-
                "base": rev.base(),
-
                "oid": rev.head(),
-
                "refs": get_refs(repo, patch.author().id(), &rev.head()).unwrap_or_default(),
-
                "discussions": rev.discussion().comments().map(|(id, c)| {
-
                    patch_comment(id, c, aliases)
-
                }).collect::<Vec<_>>(),
-
                "timestamp": rev.timestamp().as_secs(),
-
                "reviews": rev.reviews().into_iter().map(move |(_, r)| {
-
                    review(r, aliases)
-
                }).collect::<Vec<_>>(),
-
            })
-
        }).collect::<Vec<_>>(),
-
    })
-
}
+
pub(crate) mod cobs;
+
pub(crate) mod commit;
+
pub(crate) mod diff;
+
pub(crate) mod thread;

/// Returns JSON for a `reaction`.
-
fn reactions(
-
    reactions: BTreeMap<&Reaction, Vec<&ActorId>>,
-
    location: Option<&CodeLocation>,
+
pub fn reactions(
+
    reactions: BTreeMap<&cob::Reaction, Vec<&cob::ActorId>>,
+
    location: Option<&cob::CodeLocation>,
    aliases: &impl AliasStore,
) -> Vec<Value> {
    reactions
        .into_iter()
        .map(|(emoji, authors)| {
-
            if let Some(l) = location {
-
                json!({ "location": l, "emoji": emoji, "authors": authors.into_iter().map(|a|
-
                    author(&Author::from(*a), aliases.alias(a))
-
                ).collect::<Vec<_>>()})
-
            } else {
-
                json!({ "emoji": emoji, "authors": authors.into_iter().map(|a|
-
                    author(&Author::from(*a), aliases.alias(a))
-
                ).collect::<Vec<_>>()})
-
            }
+
            let authors = authors
+
                .into_iter()
+
                .map(|a| Author::new(&a.into()).as_json(aliases))
+
                .collect::<Vec<_>>();
+

+
            location.map_or(
+
                json!({
+
                  "emoji": emoji,
+
                  "authors": authors,
+
                }),
+
                |loc| {
+
                    json!({
+
                        "location": diff::CodeLocation::new(loc).as_json(),
+
                        "emoji": emoji,
+
                        "authors": authors,
+
                    })
+
                },
+
            )
        })
        .collect::<Vec<_>>()
}

-
/// Returns JSON for an `author` and fills in `alias` when present.
-
pub(crate) fn author(author: &Author, alias: Option<Alias>) -> Value {
-
    match alias {
-
        Some(alias) => json!({
-
            "id": author.id,
-
            "alias": alias,
-
        }),
-
        None => json!(author),
-
    }
-
}
-

-
/// Returns JSON for a patch `Merge` and fills in `alias` when present.
-
fn merge(nid: &NodeId, merge: &Merge, aliases: &impl AliasStore) -> Value {
-
    json!({
-
        "author": author(&Author::from(*nid), aliases.alias(nid)),
-
        "commit": merge.commit,
-
        "timestamp": merge.timestamp.as_secs(),
-
        "revision": merge.revision,
-
    })
-
}
-

-
/// Returns JSON for a patch `Review` and fills in `alias` when present.
-
fn review(review: &Review, aliases: &impl AliasStore) -> Value {
-
    let a = review.author();
-
    json!({
-
        "id": review.id(),
-
        "author": author(a, aliases.alias(a.id())),
-
        "verdict": review.verdict(),
-
        "summary": review.summary(),
-
        "comments": review.comments().map(|(id, c)| review_comment(id, c, aliases)).collect::<Vec<_>>(),
-
        "timestamp": review.timestamp().as_secs(),
-
    })
-
}
-

/// Returns JSON for an `Edit`.
-
fn edit(edit: &Edit, aliases: &impl AliasStore) -> Value {
-
    json!({
-
      "author": author(&Author::from(edit.author), aliases.alias(&edit.author)),
-
      "body": edit.body,
-
      "timestamp": edit.timestamp.as_secs(),
-
      "embeds": edit.embeds,
-
    })
-
}
-

-
/// Returns JSON for a Issue `Comment`.
-
fn issue_comment(id: &CommentId, comment: &Comment, aliases: &impl AliasStore) -> Value {
-
    json!({
-
        "id": *id,
-
        "author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
-
        "body": comment.body(),
-
        "edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
-
        "embeds": comment.embeds().to_vec(),
-
        "reactions": reactions(comment.reactions(), None, aliases),
-
        "timestamp": comment.timestamp().as_secs(),
-
        "replyTo": comment.reply_to(),
-
        "resolved": comment.is_resolved(),
-
    })
+
pub fn embeds(embeds: &[cob::Embed<cob::Uri>]) -> Vec<Value> {
+
    embeds
+
        .into_iter()
+
        .map(|e| {
+
            json!({
+
                "name": e.name,
+
                "content": e.content,
+
            })
+
        })
+
        .collect::<Vec<_>>()
}

-
/// Returns JSON for a Patch `Comment`.
-
fn patch_comment(
-
    id: &CommentId,
-
    comment: &Comment<CodeLocation>,
-
    aliases: &impl AliasStore,
-
) -> Value {
+
/// Returns JSON for an `Edit`.
+
pub fn edit(edit: &cob::thread::Edit, aliases: &impl AliasStore) -> Value {
    json!({
-
        "id": *id,
-
        "author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
-
        "body": comment.body(),
-
        "edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
-
        "embeds": comment.embeds().to_vec(),
-
        "reactions": reactions(comment.reactions(), None, aliases),
-
        "timestamp": comment.timestamp().as_secs(),
-
        "replyTo": comment.reply_to(),
-
        "location": comment.location(),
-
        "resolved": comment.is_resolved(),
+
        "author": Author::new(&edit.author.into()).as_json(aliases),
+
        "body": edit.body,
+
        "timestamp": edit.timestamp.as_secs(),
+
        "embeds": embeds(&edit.embeds)
    })
}

-
/// Returns JSON for a `Review`.
-
fn review_comment(
-
    id: &CommentId,
-
    comment: &Comment<CodeLocation>,
-
    aliases: &impl AliasStore,
-
) -> Value {
-
    json!({
-
        "id": *id,
-
        "author": author(&Author::from(comment.author()), aliases.alias(&comment.author())),
-
        "body": comment.body(),
-
        "edits": comment.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
-
        "embeds": comment.embeds().to_vec(),
-
        "reactions": reactions(comment.reactions(), None, aliases),
-
        "timestamp": comment.timestamp().as_secs(),
-
        "replyTo": comment.reply_to(),
-
        "location": comment.location(),
-
        "resolved": comment.is_resolved(),
-
    })
-
}
+
pub(crate) struct Author<'a>(&'a identity::Did);

-
/// Returns the name part of a path string.
-
fn name_in_path(path: &str) -> &str {
-
    match path.rsplit('/').next() {
-
        Some(name) => name,
-
        None => path,
+
impl<'a> Author<'a> {
+
    pub fn new(did: &'a identity::Did) -> Self {
+
        Self(did)
    }
-
}
-

-
fn get_refs(
-
    repo: &git::Repository,
-
    id: &ActorId,
-
    head: &Oid,
-
) -> Result<Vec<RefString>, refs::Error> {
-
    let remote = repo.remote(id)?;
-
    let refs = remote
-
        .refs
-
        .iter()
-
        .filter_map(|(name, o)| {
-
            if o == head {
-
                Some(name.to_owned())
-
            } else {
-
                None
-
            }
-
        })
-
        .collect::<Vec<_>>();

-
    Ok(refs)
+
    pub fn as_json(&self, aliases: &impl AliasStore) -> Value {
+
        aliases.alias(&self.0).map_or(
+
            json!({ "id": self.0 }),
+
            |alias| json!({ "id": self.0, "alias": alias, }),
+
        )
+
    }
}
added radicle-httpd/src/api/json/cobs.rs
@@ -0,0 +1,168 @@
+
use std::collections::BTreeMap;
+

+
use radicle_surf as surf;
+
use serde_json::{json, Value};
+

+
use radicle::cob;
+
use radicle::cob::{issue, patch};
+
use radicle::identity;
+
use radicle::node::AliasStore;
+
use radicle::storage::{git, refs, RemoteRepository};
+

+
use super::thread;
+
use super::{edit, reactions, Author};
+

+
pub(crate) struct Issue<'a>(&'a issue::Issue);
+

+
impl<'a> Issue<'a> {
+
    pub fn new(issue: &'a issue::Issue) -> Self {
+
        Self(issue)
+
    }
+

+
    pub fn as_json(&self, id: issue::IssueId, aliases: &impl AliasStore) -> Value {
+
        json!({
+
            "id": id.to_string(),
+
            "author": Author::new(&self.0.author().id()).as_json(aliases),
+
            "title": self.0.title(),
+
            "state": self.0.state(),
+
            "assignees": self.0.assignees().map(|assignee|
+
                Author::new(assignee).as_json(aliases)
+
            ).collect::<Vec<_>>(),
+
            "discussion": self.0.comments().map(|(id, c)|
+
                thread::Comment::Issue(&c).as_json(id, aliases)
+
            ).collect::<Vec<_>>(),
+
            "labels": self.0.labels().collect::<Vec<_>>(),
+
        })
+
    }
+
}
+

+
pub(crate) struct Patch<'a>(&'a patch::Patch);
+

+
impl<'a> Patch<'a> {
+
    pub fn new(patch: &'a patch::Patch) -> Self {
+
        Self(patch)
+
    }
+

+
    pub fn as_json(
+
        &self,
+
        id: patch::PatchId,
+
        repo: &git::Repository,
+
        aliases: &impl AliasStore,
+
    ) -> Value {
+
        json!({
+
            "id": id.to_string(),
+
            "author": Author::new(self.0.author().id()).as_json(aliases),
+
            "title": self.0.title(),
+
            "state": self.0.state(),
+
            "target": self.0.target(),
+
            "labels": self.0.labels().collect::<Vec<_>>(),
+
            "merges": self.0.merges().map(|(nid, m)| json!({
+
                "author": Author::new(&identity::Did::from(nid)).as_json(aliases),
+
                "commit": m.commit,
+
                "timestamp": m.timestamp.as_secs(),
+
                "revision": m.revision,
+
            })).collect::<Vec<_>>(),
+
            "assignees": self.0.assignees().map(|assignee|
+
                Author::new(&assignee).as_json(aliases)
+
            ).collect::<Vec<_>>(),
+
            "revisions": self.0.revisions().map(|(id, rev)|
+
                Revision::new(&rev).as_json(id, repo, aliases)
+
            ).collect::<Vec<_>>(),
+
        })
+
    }
+
}
+

+
pub(crate) struct Revision<'a>(&'a patch::Revision);
+

+
impl<'a> Revision<'a> {
+
    pub fn new(revision: &'a patch::Revision) -> Self {
+
        Self(revision)
+
    }
+

+
    pub fn as_json(
+
        &self,
+
        id: patch::RevisionId,
+
        repo: &git::Repository,
+
        aliases: &impl AliasStore,
+
    ) -> Value {
+
        let reactions = self
+
            .0
+
            .reactions()
+
            .iter()
+
            .flat_map(|(location, reaction)| {
+
                reactions(
+
                    reaction.iter().fold(
+
                        BTreeMap::new(),
+
                        |mut acc: BTreeMap<&cob::Reaction, Vec<_>>, (author, emoji)| {
+
                            acc.entry(emoji).or_default().push(author);
+
                            acc
+
                        },
+
                    ),
+
                    location.as_ref(),
+
                    aliases,
+
                )
+
            })
+
            .collect::<Vec<_>>();
+
        let refs = get_refs(repo, self.0.author().id(), &self.0.head()).unwrap_or_default();
+

+
        json!({
+
            "id": id,
+
            "author": Author::new(self.0.author().id()).as_json(aliases),
+
            "description": self.0.description(),
+
            "edits": self.0.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
            "reactions": reactions,
+
            "base": self.0.base(),
+
            "oid": self.0.head(),
+
            "refs": refs,
+
            "discussions": self.0.discussion().comments().map(|(id, c)|
+
                thread::Comment::Patch(&c).as_json(id, aliases)
+
            ).collect::<Vec<_>>(),
+
            "timestamp": self.0.timestamp().as_secs(),
+
            "reviews": self.0.reviews().into_iter().map(|(_, r)|
+
                Review::new(&r).as_json(aliases)
+
            ).collect::<Vec<_>>(),
+
        })
+
    }
+
}
+

+
pub(crate) struct Review<'a>(&'a patch::Review);
+

+
impl<'a> Review<'a> {
+
    pub fn new(review: &'a patch::Review) -> Self {
+
        Self(review)
+
    }
+

+
    pub fn as_json(&self, aliases: &impl AliasStore) -> Value {
+
        json!({
+
            "id": self.0.id(),
+
            "author": Author::new(self.0.author().id()).as_json(aliases),
+
            "verdict": self.0.verdict(),
+
            "summary": self.0.summary(),
+
            "comments": self.0.comments().map(|(id, c)|
+
                thread::Comment::Patch(&c).as_json(id, aliases)
+
            ).collect::<Vec<_>>(),
+
            "timestamp": self.0.timestamp().as_secs(),
+
        })
+
    }
+
}
+

+
fn get_refs(
+
    repo: &git::Repository,
+
    id: &cob::ActorId,
+
    head: &surf::Oid,
+
) -> Result<Vec<git::RefString>, refs::Error> {
+
    let remote = repo.remote(id)?;
+
    let refs = remote
+
        .refs
+
        .iter()
+
        .filter_map(|(name, o)| {
+
            if o == head {
+
                Some(name.to_owned())
+
            } else {
+
                None
+
            }
+
        })
+
        .collect::<Vec<_>>();
+

+
    Ok(refs)
+
}
added radicle-httpd/src/api/json/commit.rs
@@ -0,0 +1,99 @@
+
use std::path::Path;
+
use std::str;
+

+
use base64::{prelude::BASE64_STANDARD, Engine};
+
use radicle_surf as surf;
+
use serde_json::{json, Value};
+

+
pub(crate) struct Commit<'a>(&'a surf::Commit);
+

+
impl<'a> Commit<'a> {
+
    pub fn new(commit: &'a surf::Commit) -> Self {
+
        Self(commit)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        json!({
+
            "id": self.0.id,
+
            "author": {
+
                "name": self.0.author.name,
+
                "email": self.0.author.email
+
            },
+
            "summary": self.0.summary,
+
            "description": self.0.description(),
+
            "parents": self.0.parents,
+
            "committer": {
+
                "name": self.0.committer.name,
+
                "email": self.0.committer.email,
+
                "time": self.0.committer.time.seconds()
+
            }
+
        })
+
    }
+
}
+

+
pub(crate) struct Blob<'a, T: AsRef<[u8]>>(&'a surf::blob::Blob<T>);
+

+
impl<'a, T: AsRef<[u8]>> Blob<'a, T> {
+
    pub fn new(blob: &'a surf::blob::Blob<T>) -> Self {
+
        Self(blob)
+
    }
+

+
    pub fn as_json(&self, path: &str) -> Value {
+
        let content = match str::from_utf8(self.0.content()) {
+
            Ok(s) => s.to_owned(),
+
            Err(_) => BASE64_STANDARD.encode(self.0.content()),
+
        };
+

+
        json!({
+
            "binary": self.0.is_binary(),
+
            "name": name_in_path(path),
+
            "content": content,
+
            "path": path,
+
            "lastCommit": Commit(self.0.commit()).as_json()
+
        })
+
    }
+
}
+

+
pub(crate) struct Tree<'a>(&'a surf::tree::Tree);
+

+
impl<'a> Tree<'a> {
+
    pub fn new(tree: &'a surf::tree::Tree) -> Self {
+
        Self(tree)
+
    }
+

+
    pub fn as_json(&self, path: &str) -> Value {
+
        let prefix = Path::new(path);
+
        let entries = self
+
            .0
+
            .entries()
+
            .iter()
+
            .map(|entry| {
+
                json!({
+
                    "path": prefix.join(entry.name()),
+
                    "oid": entry.object_id(),
+
                    "name": entry.name(),
+
                    "kind": match entry.entry() {
+
                        surf::tree::EntryKind::Tree(_) => "tree",
+
                        surf::tree::EntryKind::Blob(_) => "blob",
+
                        surf::tree::EntryKind::Submodule { .. } => "submodule"
+
                    },
+
                })
+
            })
+
            .collect::<Vec<_>>();
+

+
        json!({
+
            "entries": &entries,
+
            "lastCommit": Commit::new(self.0.commit()).as_json(),
+
            "name": name_in_path(path),
+
            "path": path,
+
        })
+
    }
+
}
+

+
/// Returns the name part of a path string.
+
fn name_in_path(path: &str) -> &str {
+
    match path.rsplit('/').next() {
+
        Some(name) => name,
+
        None => path,
+
    }
+
}
added radicle-httpd/src/api/json/diff.rs
@@ -0,0 +1,246 @@
+
use radicle_surf as surf;
+
use serde_json::{json, Value};
+

+
use radicle::cob;
+

+
pub(crate) struct Diff<'a>(&'a surf::diff::Diff);
+

+
impl<'a> Diff<'a> {
+
    pub fn new(diff: &'a surf::diff::Diff) -> Self {
+
        Self(diff)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        let s = self.0.stats();
+
        json!({
+
            "files": self.0.files().into_iter().map(|f| {
+
                match f {
+
                    surf::diff::FileDiff::Added(added) => json!({
+
                        "status": "added",
+
                        "path": added.path,
+
                        "diff": DiffContent::new(&added.diff).as_json(),
+
                        "new": DiffFile::new(&added.new).as_json(),
+
                    }),
+
                    surf::diff::FileDiff::Deleted(deleted) => json!({
+
                        "status": "deleted",
+
                        "path": deleted.path,
+
                        "diff": DiffContent::new(&deleted.diff).as_json(),
+
                        "old": DiffFile::new(&deleted.old).as_json(),
+
                    }),
+
                    surf::diff::FileDiff::Modified(modified) => json!({
+
                        "status": "modified",
+
                        "path": modified.path,
+
                        "diff": DiffContent::new(&modified.diff).as_json(),
+
                        "old": DiffFile::new(&modified.old).as_json(),
+
                        "new": DiffFile::new(&modified.new).as_json(),
+
                    }),
+
                    surf::diff::FileDiff::Moved(moved) => {
+
                        if moved.old == moved.new {
+
                            json!({
+
                                "status": "moved",
+
                                "oldPath": moved.old_path,
+
                                "newPath": moved.new_path,
+
                                "current": DiffFile::new(&moved.new).as_json(),
+
                            })
+
                        } else {
+
                            json!({
+
                                "status": "moved",
+
                                "oldPath": moved.old_path,
+
                                "newPath": moved.new_path,
+
                                "old": DiffFile::new(&moved.old).as_json(),
+
                                "new": DiffFile::new(&moved.new).as_json(),
+
                                "diff": DiffContent::new(&moved.diff).as_json()
+
                            })
+
                        }
+
                    },
+
                    surf::diff::FileDiff::Copied(copied) => {
+
                        if copied.old == copied.new {
+
                            json!({
+
                                "status": "copied",
+
                                "oldPath": copied.old_path,
+
                                "newPath": copied.new_path,
+
                                "current": DiffFile::new(&copied.new).as_json()
+
                            })
+
                        } else {
+
                            json!({
+
                                "status": "copied",
+
                                "oldPath": copied.old_path,
+
                                "newPath": copied.new_path,
+
                                "old": DiffFile::new(&copied.old).as_json(),
+
                                "new": DiffFile::new(&copied.new).as_json(),
+
                                "diff": DiffContent::new(&copied.diff).as_json()
+
                            })
+
                        }
+
                    },
+
                }
+
            }).collect::<Vec<_>>(),
+
            "stats": json!({
+
                 "filesChanged": s.files_changed,
+
                 "insertions": s.insertions,
+
                 "deletions": s.deletions,
+
            }),
+
        })
+
    }
+
}
+

+
pub(crate) struct CodeLocation<'a>(&'a cob::CodeLocation);
+

+
impl<'a> CodeLocation<'a> {
+
    pub fn new(location: &'a cob::CodeLocation) -> Self {
+
        Self(location)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        match (&self.0.old, &self.0.new) {
+
            (Some(old), Some(new)) => json!({
+
                "commit": self.0.commit,
+
                "path": self.0.path,
+
                "old": code_range(old),
+
                "new": code_range(new)
+
            }),
+
            (None, Some(new)) => json!({
+
                "commit": self.0.commit,
+
                "path": self.0.path,
+
                "new": code_range(new)
+
            }),
+
            (Some(old), None) => json!({
+
                "commit": self.0.commit,
+
                "path": self.0.path,
+
                "old": code_range(old)
+
            }),
+
            (None, None) => json!({
+
                "commit": self.0.commit,
+
                "path": self.0.path
+
            }),
+
        }
+
    }
+
}
+

+
fn code_range(range: &cob::CodeRange) -> Value {
+
    match range {
+
        cob::CodeRange::Lines { range } => json!({
+
            "type": "lines",
+
            "range": range
+
        }),
+
        cob::CodeRange::Chars { line, range } => {
+
            json!({ "type": "chars", "line": line, "range": range })
+
        }
+
    }
+
}
+

+
pub(crate) struct DiffContent<'a>(&'a surf::diff::DiffContent);
+

+
impl<'a> DiffContent<'a> {
+
    pub fn new(value: &'a surf::diff::DiffContent) -> Self {
+
        Self(value)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        match self.0 {
+
            surf::diff::DiffContent::Binary => json!({ "type": "binary" }),
+
            surf::diff::DiffContent::Empty => json!({ "type": "empty" }),
+
            surf::diff::DiffContent::Plain { hunks, stats, eof } => {
+
                let hunks = hunks
+
                    .iter()
+
                    .map(|h| Hunk::new(&h).as_json())
+
                    .collect::<Vec<_>>();
+

+
                json!({
+
                    "type": "plain",
+
                    "hunks": hunks,
+
                    "stats": json!({
+
                        "additions": stats.additions,
+
                        "deletions": stats.deletions
+
                    }),
+
                    "eof": match eof {
+
                        surf::diff::EofNewLine::OldMissing => "oldMissing",
+
                        surf::diff::EofNewLine::NewMissing => "newMissing",
+
                        surf::diff::EofNewLine::BothMissing => "bothMissing",
+
                        surf::diff::EofNewLine::NoneMissing => "noneMissing",
+
                    }
+
                })
+
            }
+
        }
+
    }
+
}
+

+
pub(crate) struct DiffFile<'a>(&'a surf::diff::DiffFile);
+

+
impl<'a> DiffFile<'a> {
+
    pub fn new(value: &'a surf::diff::DiffFile) -> Self {
+
        Self(value)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        json!({ "oid": self.0.oid, "mode": match self.0.mode {
+
            surf::diff::FileMode::Blob => "blob",
+
            surf::diff::FileMode::BlobExecutable => "blobExecutable",
+
            surf::diff::FileMode::Tree => "tree",
+
            surf::diff::FileMode::Link => "link",
+
            surf::diff::FileMode::Commit => "commit",
+
        } })
+
    }
+
}
+

+
pub(crate) struct Modification<'a>(&'a surf::diff::Modification);
+

+
impl<'a> Modification<'a> {
+
    pub fn new(value: &'a surf::diff::Modification) -> Self {
+
        Self(value)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        match self.0 {
+
            surf::diff::Modification::Addition(addition) => {
+
                json!({
+
                    "type": "addition",
+
                    "line": addition.line,
+
                    "lineNo": addition.line_no
+
                })
+
            }
+
            surf::diff::Modification::Deletion(deletion) => {
+
                json!({
+
                    "type": "deletion",
+
                    "line": deletion.line,
+
                    "lineNo": deletion.line_no
+
                })
+
            }
+
            surf::diff::Modification::Context {
+
                line,
+
                line_no_old,
+
                line_no_new,
+
            } => {
+
                json!({
+
                    "type": "context",
+
                    "line": line,
+
                    "lineNoOld": line_no_old,
+
                    "lineNoNew": line_no_new
+
                })
+
            }
+
        }
+
    }
+
}
+

+
pub(crate) struct Hunk<'a>(&'a surf::diff::Hunk<surf::diff::Modification>);
+

+
impl<'a> Hunk<'a> {
+
    pub fn new(value: &'a surf::diff::Hunk<surf::diff::Modification>) -> Self {
+
        Self(value)
+
    }
+

+
    pub fn as_json(&self) -> Value {
+
        let lines = self
+
            .0
+
            .lines
+
            .iter()
+
            .map(|line| Modification::new(&line).as_json())
+
            .collect::<Vec<_>>();
+

+
        json!({
+
            "header": self.0.header,
+
            "lines": lines,
+
            "old": self.0.old,
+
            "new": self.0.new,
+
        })
+
    }
+
}
added radicle-httpd/src/api/json/thread.rs
@@ -0,0 +1,43 @@
+
use serde_json::{json, Value};
+

+
use radicle::cob;
+
use radicle::cob::CodeLocation;
+
use radicle::git::Oid;
+
use radicle::node::AliasStore;
+

+
use super::{diff, edit, embeds, reactions, Author};
+

+
pub(crate) enum Comment<'a> {
+
    Patch(&'a cob::thread::Comment<CodeLocation>),
+
    Issue(&'a cob::thread::Comment),
+
}
+

+
impl<'a> Comment<'a> {
+
    pub fn as_json(&self, id: &Oid, aliases: &impl AliasStore) -> Value {
+
        match self {
+
            Comment::Issue(c) => json!({
+
                "id": *id,
+
                "author": Author::new(&c.author().into()).as_json(aliases),
+
                "body": c.body(),
+
                "edits": c.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
                "embeds": embeds(c.embeds()),
+
                "reactions": reactions(c.reactions(), None, aliases),
+
                "timestamp": c.timestamp().as_secs(),
+
                "replyTo": c.reply_to(),
+
                "resolved": c.is_resolved(),
+
            }),
+
            Comment::Patch(c) => json!({
+
                "id": *id,
+
                "author": Author::new(&c.author().into()).as_json(aliases),
+
                "body": c.body(),
+
                "edits": c.edits().map(|e| edit(e, aliases)).collect::<Vec<_>>(),
+
                "embeds": embeds(c.embeds()),
+
                "reactions": reactions(c.reactions(), None, aliases),
+
                "timestamp": c.timestamp().as_secs(),
+
                "replyTo": c.reply_to(),
+
                "location": c.location().map(|l| diff::CodeLocation::new(&l).as_json()),
+
                "resolved": c.is_resolved(),
+
            }),
+
        }
+
    }
+
}
modified radicle-httpd/src/api/v1/repos.rs
@@ -201,7 +201,7 @@ async fn history_handler(
        .filter_map(|commit| {
            let commit = commit.ok()?;
            let time = commit.committer.time.seconds();
-
            let commit = api::json::commit(&commit);
+
            let commit = api::json::commit::Commit::new(&commit).as_json();
            match (since, until) {
                (Some(since), Some(until)) if time >= since && time < until => Some(commit),
                (Some(since), None) if time >= since => Some(commit),
@@ -279,8 +279,8 @@ async fn commit_handler(
    });

    let response: serde_json::Value = json!({
-
      "commit": api::json::commit(&commit),
-
      "diff": diff,
+
      "commit": api::json::commit::Commit::new(&commit).as_json(),
+
      "diff": api::json::diff::Diff::new(&diff).as_json(),
      "files": files,
      "branches": branches
    });
@@ -346,7 +346,7 @@ async fn diff_handler(
                false
            }
        })
-
        .map(|r| r.map(|c| api::json::commit(&c)))
+
        .map(|r| r.map(|c| api::json::commit::Commit::new(&c).as_json()))
        .collect::<Result<Vec<_>, _>>()?;

    let response = json!({ "diff": diff, "files": files, "commits": commits });
@@ -409,7 +409,7 @@ async fn tree_handler(

    let repo = Repository::open(repo.path())?;
    let tree = repo.tree(sha, &path)?;
-
    let response = api::json::tree(&tree, &path);
+
    let response = api::json::commit::Tree::new(&tree).as_json(&path);

    if let Some(cache) = &ctx.cache {
        let cache = &mut cache.tree.lock().await;
@@ -518,7 +518,9 @@ async fn blob_handler(
                .into_response(),
        );
    }
-
    Ok::<_, Error>(immutable_response(api::json::blob(&blob, &path)).into_response())
+
    Ok::<_, Error>(
+
        immutable_response(api::json::commit::Blob::new(&blob).as_json(&path)).into_response(),
+
    )
}

/// Get repo readme.
@@ -557,7 +559,8 @@ async fn readme_handler(
            }

            return Ok::<_, Error>(
-
                immutable_response(api::json::blob(&blob, &path)).into_response(),
+
                immutable_response(api::json::commit::Blob::new(&blob).as_json(&path))
+
                    .into_response(),
            );
        }
    }
@@ -594,7 +597,7 @@ async fn issues_handler(
    let aliases = &ctx.profile.aliases();
    let issues = issues
        .into_iter()
-
        .map(|(id, issue)| api::json::issue(id, issue, aliases))
+
        .map(|(id, issue)| api::json::cobs::Issue::new(&issue).as_json(id, aliases))
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();
@@ -616,7 +619,9 @@ async fn issue_handler(
        .ok_or(Error::NotFound)?;
    let aliases = ctx.profile.aliases();

-
    Ok::<_, Error>(Json(api::json::issue(issue_id.into(), issue, &aliases)))
+
    Ok::<_, Error>(Json(
+
        api::json::cobs::Issue::new(&issue).as_json(issue_id.into(), &aliases),
+
    ))
}

/// Get repo patches list.
@@ -647,7 +652,7 @@ async fn patches_handler(
    let aliases = ctx.profile.aliases();
    let patches = patches
        .into_iter()
-
        .map(|(id, patch)| api::json::patch(id, patch, &repo, &aliases))
+
        .map(|(id, patch)| api::json::cobs::Patch::new(&patch).as_json(id, &repo, &aliases))
        .skip(page * per_page)
        .take(per_page)
        .collect::<Vec<_>>();
@@ -666,9 +671,8 @@ async fn patch_handler(
    let patch = patches.get(&patch_id.into())?.ok_or(Error::NotFound)?;
    let aliases = ctx.profile.aliases();

-
    Ok::<_, Error>(Json(api::json::patch(
+
    Ok::<_, Error>(Json(api::json::cobs::Patch::new(&patch).as_json(
        patch_id.into(),
-
        patch,
        &repo,
        &aliases,
    )))