json.rs
1 //! Utilities for building JSON responses of our API. 2 3 use std::path::Path; 4 use std::str; 5 6 use base64::prelude::{Engine, BASE64_STANDARD}; 7 use serde::Serialize; 8 use serde_json::{json, Value}; 9 10 use radicle::cob::issue::{Issue, IssueId}; 11 use radicle::cob::patch::Merge; 12 use radicle::cob::patch::Review; 13 use radicle::cob::patch::{Patch, PatchId}; 14 use radicle::cob::thread; 15 use radicle::cob::thread::CommentId; 16 use radicle::cob::{ActorId, Author, Embed, Reaction, Timestamp, Uri}; 17 use radicle::git::RefString; 18 use radicle::node::{Alias, AliasStore}; 19 use radicle::prelude::NodeId; 20 use radicle::storage::{git, refs, ReadRepository}; 21 use radicle_surf::blob::Blob; 22 use radicle_surf::tree::Tree; 23 use radicle_surf::{Commit, Oid, Stats}; 24 25 use crate::api::auth::Session; 26 27 /// Returns JSON of a commit. 28 pub(crate) fn commit(commit: &Commit) -> Value { 29 json!({ 30 "id": commit.id, 31 "author": { 32 "name": commit.author.name, 33 "email": commit.author.email 34 }, 35 "summary": commit.summary, 36 "description": commit.description(), 37 "parents": commit.parents, 38 "committer": { 39 "name": commit.committer.name, 40 "email": commit.committer.email, 41 "time": commit.committer.time.seconds() 42 } 43 }) 44 } 45 46 /// Returns JSON of a session. 47 pub(crate) fn session(session_id: String, session: &Session) -> Value { 48 json!({ 49 "sessionId": session_id, 50 "status": session.status, 51 "publicKey": session.public_key, 52 "alias": session.alias, 53 "issuedAt": session.issued_at.unix_timestamp(), 54 "expiresAt": session.expires_at.unix_timestamp() 55 }) 56 } 57 58 /// Returns JSON for a blob with a given `path`. 59 pub(crate) fn blob<T: AsRef<[u8]>>(blob: &Blob<T>, path: &str) -> Value { 60 json!({ 61 "binary": blob.is_binary(), 62 "name": name_in_path(path), 63 "content": blob_content(blob), 64 "path": path, 65 "lastCommit": commit(blob.commit()) 66 }) 67 } 68 69 /// Returns a string for the blob content, encoded in base64 if binary. 70 pub fn blob_content<T: AsRef<[u8]>>(blob: &Blob<T>) -> String { 71 match str::from_utf8(blob.content()) { 72 Ok(s) => s.to_owned(), 73 Err(_) => BASE64_STANDARD.encode(blob.content()), 74 } 75 } 76 77 /// Returns JSON for a tree with a given `path` and `stats`. 78 pub(crate) fn tree(tree: &Tree, path: &str, stats: &Stats) -> Value { 79 let prefix = Path::new(path); 80 let entries = tree 81 .entries() 82 .iter() 83 .map(|entry| { 84 json!({ 85 "path": prefix.join(entry.name()), 86 "name": entry.name(), 87 "kind": if entry.is_tree() { "tree" } else { "blob" }, 88 }) 89 }) 90 .collect::<Vec<_>>(); 91 92 json!({ 93 "entries": &entries, 94 "lastCommit": commit(tree.commit()), 95 "name": name_in_path(path), 96 "path": path, 97 "stats": stats, 98 }) 99 } 100 101 /// Returns JSON for an `issue`. 102 pub(crate) fn issue(id: IssueId, issue: Issue, aliases: &impl AliasStore) -> Value { 103 json!({ 104 "id": id.to_string(), 105 "author": author(&issue.author(), aliases.alias(issue.author().id())), 106 "title": issue.title(), 107 "state": issue.state(), 108 "assignees": issue.assigned().collect::<Vec<_>>(), 109 "discussion": issue 110 .comments() 111 .map(|(id, comment)| Comment::new(id, comment, aliases)) 112 .collect::<Vec<_>>(), 113 "labels": issue.labels().collect::<Vec<_>>(), 114 }) 115 } 116 117 /// Returns JSON for a `patch`. 118 pub(crate) fn patch( 119 id: PatchId, 120 patch: Patch, 121 repo: &git::Repository, 122 aliases: &impl AliasStore, 123 ) -> Value { 124 json!({ 125 "id": id.to_string(), 126 "author": author(patch.author(), aliases.alias(patch.author().id())), 127 "title": patch.title(), 128 "state": patch.state(), 129 "target": patch.target(), 130 "labels": patch.labels().collect::<Vec<_>>(), 131 "merges": patch.merges().map(|(nid, m)| merge(m, nid, aliases.alias(nid))).collect::<Vec<_>>(), 132 "assignees": patch.assignees().collect::<Vec<_>>(), 133 "revisions": patch.revisions().map(|(id, rev)| { 134 json!({ 135 "id": id, 136 "author": author(rev.author(), aliases.alias(rev.author().id())), 137 "description": rev.description(), 138 "base": rev.base(), 139 "oid": rev.head(), 140 "refs": get_refs(repo, patch.author().id(), &rev.head()).unwrap_or(vec![]), 141 "discussions": rev.discussion().comments() 142 .map(|(id, comment)| Comment::new(id, comment, aliases)) 143 .collect::<Vec<_>>(), 144 "timestamp": rev.timestamp().as_secs(), 145 "reviews": rev.reviews().map(|(nid, _review)| review(nid, aliases.alias(nid), _review)).collect::<Vec<_>>(), 146 }) 147 }).collect::<Vec<_>>(), 148 }) 149 } 150 151 /// Returns JSON for an `author` and fills in `alias` when present. 152 fn author(author: &Author, alias: Option<Alias>) -> Value { 153 match alias { 154 Some(alias) => json!({ 155 "id": author.id, 156 "alias": alias, 157 }), 158 None => json!(author), 159 } 160 } 161 162 /// Returns JSON for a patch `Merge` and fills in `alias` when present. 163 fn merge(merge: &Merge, nid: &NodeId, alias: Option<Alias>) -> Value { 164 match alias { 165 Some(alias) => json!({ 166 "author": { 167 "id": nid, 168 "alias": alias, 169 }, 170 "commit": merge.commit, 171 "timestamp": merge.timestamp.as_secs(), 172 "revision": merge.revision, 173 }), 174 None => json!({ 175 "author": { 176 "id": nid, 177 }, 178 "commit": merge.commit, 179 "timestamp": merge.timestamp.as_secs(), 180 "revision": merge.revision, 181 }), 182 } 183 } 184 185 /// Returns JSON for a patch `Review` and fills in `alias` when present. 186 fn review(nid: &NodeId, alias: Option<Alias>, review: &Review) -> Value { 187 match alias { 188 Some(alias) => json!({ 189 "author": { 190 "id": nid, 191 "alias": alias, 192 }, 193 "verdict": review.verdict(), 194 "summary": review.summary(), 195 "comments": review.comments().collect::<Vec<_>>(), 196 "timestamp": review.timestamp().as_secs(), 197 }), 198 None => json!({ 199 "author": { 200 "id": nid, 201 }, 202 "verdict": review.verdict(), 203 "summary": review.summary(), 204 "comments": review.comments().collect::<Vec<_>>(), 205 "timestamp": review.timestamp().as_secs(), 206 }), 207 } 208 } 209 210 /// Returns the name part of a path string. 211 fn name_in_path(path: &str) -> &str { 212 match path.rsplit('/').next() { 213 Some(name) => name, 214 None => path, 215 } 216 } 217 218 fn get_refs( 219 repo: &git::Repository, 220 id: &ActorId, 221 head: &Oid, 222 ) -> Result<Vec<RefString>, refs::Error> { 223 let remote = repo.remote(id)?; 224 let refs = remote 225 .refs 226 .iter() 227 .filter_map(|(name, o)| { 228 if o == head { 229 Some(name.to_owned()) 230 } else { 231 None 232 } 233 }) 234 .collect::<Vec<_>>(); 235 236 Ok(refs) 237 } 238 239 #[derive(Serialize)] 240 #[serde(rename_all = "camelCase")] 241 struct Comment<'a> { 242 id: CommentId, 243 author: Value, 244 body: &'a str, 245 embeds: Vec<Embed<Uri>>, 246 reactions: Vec<(&'a ActorId, &'a Reaction)>, 247 #[serde(with = "radicle::serde_ext::localtime::time")] 248 timestamp: Timestamp, 249 reply_to: Option<CommentId>, 250 resolved: bool, 251 } 252 253 impl<'a> Comment<'a> { 254 fn new(id: &'a CommentId, comment: &'a thread::Comment, aliases: &impl AliasStore) -> Self { 255 let comment_author = Author::new(comment.author()); 256 Self { 257 id: *id, 258 author: author(&comment_author, aliases.alias(comment_author.id())), 259 body: comment.body(), 260 embeds: comment.embeds().to_vec(), 261 reactions: comment.reactions().collect::<Vec<_>>(), 262 timestamp: comment.timestamp(), 263 reply_to: comment.reply_to(), 264 resolved: comment.resolved(), 265 } 266 } 267 }