Skip to main content

lit/api/
mod.rs

1//! Self-Describing API (JSON-LD / Hydra)
2//!
3//! Adds hypermedia links and JSON-LD context to lit's API responses,
4//! making the API discoverable and self-documenting for agents.
5
6use serde::{Deserialize, Serialize};
7
8/// JSON-LD context for lit API responses
9pub const LIT_CONTEXT: &str = "https://lit.nervosys.com/api/v1/context.jsonld";
10
11/// A hypermedia link (Hydra-style)
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ApiLink {
14    /// Relation type (e.g., "self", "next", "parent", "children")
15    pub rel: String,
16    /// Target URI or path
17    pub href: String,
18    /// HTTP method (GET, POST, etc.)
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub method: Option<String>,
21    /// Human-readable title
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub title: Option<String>,
24}
25
26/// Self-describing API envelope that wraps any response with JSON-LD context
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ApiEnvelope<T: Serialize> {
29    /// JSON-LD context
30    #[serde(rename = "@context")]
31    pub context: String,
32    /// Resource type
33    #[serde(rename = "@type")]
34    pub resource_type: String,
35    /// Hypermedia links for discoverability
36    #[serde(rename = "_links")]
37    pub links: Vec<ApiLink>,
38    /// The actual response data
39    pub data: T,
40}
41
42impl<T: Serialize> ApiEnvelope<T> {
43    /// Wrap a response with JSON-LD context and links
44    pub fn wrap(resource_type: &str, data: T, links: Vec<ApiLink>) -> Self {
45        ApiEnvelope {
46            context: LIT_CONTEXT.to_string(),
47            resource_type: resource_type.to_string(),
48            links,
49            data,
50        }
51    }
52
53    /// Serialize to JSON
54    pub fn to_json(&self) -> Result<String, serde_json::Error> {
55        serde_json::to_string_pretty(self)
56    }
57}
58
59/// Generate standard links for a repository resource
60pub fn repo_links(repo_name: &str) -> Vec<ApiLink> {
61    vec![
62        ApiLink {
63            rel: "self".to_string(),
64            href: format!("/repos/{}", repo_name),
65            method: Some("GET".to_string()),
66            title: Some("This repository".to_string()),
67        },
68        ApiLink {
69            rel: "commits".to_string(),
70            href: format!("/repos/{}/commits", repo_name),
71            method: Some("GET".to_string()),
72            title: Some("List commits".to_string()),
73        },
74        ApiLink {
75            rel: "branches".to_string(),
76            href: format!("/repos/{}/branches", repo_name),
77            method: Some("GET".to_string()),
78            title: Some("List branches".to_string()),
79        },
80        ApiLink {
81            rel: "issues".to_string(),
82            href: format!("/repos/{}/issues", repo_name),
83            method: Some("GET".to_string()),
84            title: Some("List issues".to_string()),
85        },
86        ApiLink {
87            rel: "prs".to_string(),
88            href: format!("/repos/{}/prs", repo_name),
89            method: Some("GET".to_string()),
90            title: Some("List pull requests".to_string()),
91        },
92        ApiLink {
93            rel: "peers".to_string(),
94            href: format!("/repos/{}/peers", repo_name),
95            method: Some("GET".to_string()),
96            title: Some("List federated peers".to_string()),
97        },
98        ApiLink {
99            rel: "events".to_string(),
100            href: format!("/repos/{}/events", repo_name),
101            method: Some("GET".to_string()),
102            title: Some("Event stream".to_string()),
103        },
104    ]
105}
106
107/// Generate links for a commit resource
108pub fn commit_links(repo_name: &str, commit_hash: &str) -> Vec<ApiLink> {
109    vec![
110        ApiLink {
111            rel: "self".to_string(),
112            href: format!("/repos/{}/commits/{}", repo_name, commit_hash),
113            method: Some("GET".to_string()),
114            title: Some("This commit".to_string()),
115        },
116        ApiLink {
117            rel: "tree".to_string(),
118            href: format!("/repos/{}/tree/{}", repo_name, commit_hash),
119            method: Some("GET".to_string()),
120            title: Some("File tree at this commit".to_string()),
121        },
122        ApiLink {
123            rel: "parent".to_string(),
124            href: format!("/repos/{}/commits/{}~1", repo_name, commit_hash),
125            method: Some("GET".to_string()),
126            title: Some("Parent commit".to_string()),
127        },
128        ApiLink {
129            rel: "diff".to_string(),
130            href: format!("/repos/{}/diff/{}", repo_name, commit_hash),
131            method: Some("GET".to_string()),
132            title: Some("Diff for this commit".to_string()),
133        },
134    ]
135}
136
137/// Generate links for a DID identity resource
138pub fn identity_links(did: &str) -> Vec<ApiLink> {
139    let safe_did = did.replace(':', "_");
140    vec![
141        ApiLink {
142            rel: "self".to_string(),
143            href: format!("/identities/{}", safe_did),
144            method: Some("GET".to_string()),
145            title: Some("This identity".to_string()),
146        },
147        ApiLink {
148            rel: "trust".to_string(),
149            href: format!("/identities/{}/trust", safe_did),
150            method: Some("GET".to_string()),
151            title: Some("Trust score".to_string()),
152        },
153        ApiLink {
154            rel: "tokens".to_string(),
155            href: format!("/identities/{}/tokens", safe_did),
156            method: Some("GET".to_string()),
157            title: Some("UCAN tokens".to_string()),
158        },
159        ApiLink {
160            rel: "delegations".to_string(),
161            href: format!("/identities/{}/delegations", safe_did),
162            method: Some("GET".to_string()),
163            title: Some("Task delegations".to_string()),
164        },
165    ]
166}
167
168/// API root entry point with all discoverable endpoints
169pub fn api_root_links() -> Vec<ApiLink> {
170    vec![
171        ApiLink {
172            rel: "self".to_string(),
173            href: "/".to_string(),
174            method: Some("GET".to_string()),
175            title: Some("API root".to_string()),
176        },
177        ApiLink {
178            rel: "repos".to_string(),
179            href: "/repos".to_string(),
180            method: Some("GET".to_string()),
181            title: Some("List repositories".to_string()),
182        },
183        ApiLink {
184            rel: "identities".to_string(),
185            href: "/identities".to_string(),
186            method: Some("GET".to_string()),
187            title: Some("List identities".to_string()),
188        },
189        ApiLink {
190            rel: "events".to_string(),
191            href: "/events".to_string(),
192            method: Some("GET".to_string()),
193            title: Some("Global event stream".to_string()),
194        },
195        ApiLink {
196            rel: "federation".to_string(),
197            href: "/federation".to_string(),
198            method: Some("GET".to_string()),
199            title: Some("Federation status".to_string()),
200        },
201    ]
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_api_envelope() {
210        let data = serde_json::json!({"name": "test-repo"});
211        let envelope = ApiEnvelope::wrap("Repository", data, repo_links("test-repo"));
212        let json = envelope.to_json().unwrap();
213        assert!(json.contains("@context"));
214        assert!(json.contains("@type"));
215        assert!(json.contains("_links"));
216        assert!(json.contains("test-repo"));
217    }
218
219    #[test]
220    fn test_repo_links() {
221        let links = repo_links("myrepo");
222        assert!(links.iter().any(|l| l.rel == "self"));
223        assert!(links.iter().any(|l| l.rel == "commits"));
224        assert!(links.iter().any(|l| l.rel == "issues"));
225    }
226
227    #[test]
228    fn test_api_root() {
229        let links = api_root_links();
230        assert!(links.iter().any(|l| l.rel == "repos"));
231        assert!(links.iter().any(|l| l.rel == "identities"));
232        assert!(links.iter().any(|l| l.rel == "federation"));
233    }
234}