pr_01m47d15m3e54sn21z27rpy5n9/apps/api/src/openapi.rs

312 lines11,911 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server in Rust; a public index at the API root1//! The OpenAPI document, generated from the same list the routes are.
2
3use serde_json::{Map, Value, json};
4
5use crate::operations::Op;
6use crate::rest::{ROUTES, Route};
7
8/// The section of the API reference an operation is listed under.
9fn tag(op: Op) -> &'static str {
10 let name = op.name();
11 if op == Op::Whoami || name.contains("workspace") {
12 "Accounts"
13 } else if name.contains("session") {
14 "Sessions"
15 } else if name.contains("pull_request") {
16 "Pull requests"
17 } else if ["issue", "label", "comment"]
18 .iter()
19 .any(|word| name.contains(word))
20 {
21 "Issues"
22 } else {
23 "Repositories"
24 }
25}
26
27/// A short title from an operation name: `create_issue` is "Create issue".
28fn title(op: Op) -> String {
29 if op == Op::Whoami {
30 return "Get the current user".to_owned();
31 }
32 let words = op.name().replace('_', " ");
33 let mut letters = words.chars();
34 match letters.next() {
35 Some(first) => first.to_uppercase().chain(letters).collect(),
36 None => words,
37 }
38}
39
40/// `/v1/repos/:owner/:name` as OpenAPI writes it: `/v1/repos/{owner}/{name}`.
41fn openapi_path(route: &Route) -> String {
42 route
43 .path
44 .split('/')
45 .map(|segment| match segment.strip_prefix(':') {
46 Some(name) => format!("{{{name}}}"),
47 None => segment.to_owned(),
48 })
49 .collect::<Vec<_>>()
50 .join("/")
51}
52
53fn error_response(description: &str) -> Value {
54 json!({
55 "description": description,
56 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
57 })
58}
59
60fn operation(route: &Route) -> Value {
61 let op = route.op;
62 let path_params: Vec<&str> = route.params().collect();
63 // `owner` and `name` in the path stand for the operation's `repo` input.
64 let covered = |name: &str| name == "repo" || path_params.contains(&name);
65 let mut properties = op.properties();
66 properties.retain(|name, _| !covered(name));
67 let required: Vec<String> = op
68 .required()
69 .into_iter()
70 .filter(|name| !covered(name))
71 .collect();
72
73 let mut parameters: Vec<Value> = path_params
74 .iter()
75 .map(|name| {
76 json!({
77 "name": name,
78 "in": "path",
79 "required": true,
80 "schema": { "type": if *name == "number" { "integer" } else { "string" } },
81 })
82 })
83 .collect();
84 let mut body = Value::Null;
85 if route.method == "GET" {
86 for (name, key) in route.query {
87 parameters.push(json!({
88 "name": name,
89 "in": "query",
90 "required": false,
91 "schema": properties.get(*key).cloned().unwrap_or_else(|| json!({})),
92 }));
93 }
94 } else if !properties.is_empty() {
95 let mut schema = json!({ "type": "object", "properties": properties });
96 if !required.is_empty() {
97 schema["required"] = json!(required);
98 }
99 body = json!({
100 "required": !required.is_empty(),
101 "content": { "application/json": { "schema": schema } },
102 });
103 }
104
105 let mut described = json!({
106 "operationId": op.name(),
107 "tags": [tag(op)],
108 "summary": title(op),
109 "description": op.description(),
110 "parameters": parameters,
111 "responses": {
112 "200": {
113 "description": "Success.",
114 "content": { "application/json": { "schema": {} } },
115 },
116 "401": error_response("A token is required, or the one sent is not valid."),
117 "403": error_response("Signed in, but not allowed to do this."),
118 "404": error_response("It does not exist, or you cannot see it."),
119 "409": error_response("The request conflicts with the current state."),
120 "422": error_response("The input is not valid."),
121 },
122 });
123 if !body.is_null() {
124 described["requestBody"] = body;
125 }
126 described
127}
128
129/// Entries for device sign-in, which is not an operation.
130fn onboarding() -> Map<String, Value> {
131 let paths = json!({
132 "/v1/device/code": {
133 "post": {
134 "operationId": "device_code",
135 "tags": ["Accounts"],
136 "summary": "Start signing in",
137 "description": "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/v1/device/token`.",
138 "security": [],
139 "requestBody": {
140 "content": { "application/json": { "schema": {
141 "type": "object",
142 "properties": {
143 "client_name": {
144 "type": "string",
145 "description": "What is asking, shown to the person approving. For example, Claude Code.",
146 },
147 },
148 } } },
149 },
150 "responses": { "200": {
151 "description": "The codes for this sign-in.",
152 "content": { "application/json": { "schema": {
153 "type": "object",
154 "properties": {
155 "device_code": { "type": "string", "description": "Secret. Send it to /v1/device/token." },
156 "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." },
157 "verification_uri": { "type": "string" },
158 "verification_uri_complete": {
159 "type": "string",
160 "description": "The link to give the person; it carries the code.",
161 },
162 "expires_in": { "type": "integer", "description": "Seconds until the codes expire." },
163 "interval": { "type": "integer", "description": "Seconds to wait between polls." },
164 },
165 } } },
166 } },
167 },
168 },
169 "/v1/device/token": {
170 "post": {
171 "operationId": "device_token",
172 "tags": ["Accounts"],
173 "summary": "Finish signing in",
174 "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.",
175 "security": [],
176 "requestBody": {
177 "required": true,
178 "content": { "application/json": { "schema": {
179 "type": "object",
180 "required": ["device_code"],
181 "properties": { "device_code": { "type": "string" } },
182 } } },
183 },
184 "responses": { "200": {
185 "description": "The state of the sign-in.",
186 "content": { "application/json": { "schema": {
187 "type": "object",
188 "required": ["status"],
189 "properties": {
190 "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] },
191 "token": { "type": "string", "description": "Present when approved." },
192 "username": { "type": "string" },
193 "verified": {
194 "type": "boolean",
195 "description": "Whether the account's email is confirmed.",
196 },
197 },
198 } } },
199 } },
200 },
201 },
202 });
203 match paths {
204 Value::Object(paths) => paths,
205 _ => Map::new(),
206 }
207}
208
209pub fn document() -> Value {
210 let mut paths = onboarding();
211 for route in ROUTES {
212 let entry = paths
213 .entry(openapi_path(route))
214 .or_insert_with(|| json!({}));
215 entry[route.method.to_lowercase()] = operation(route);
216 }
217 json!({
218 "openapi": "3.1.0",
219 "info": {
220 "title": "g1t API",
221 "version": "1",
222 "description": "The REST API for g1t, a git forge built for agents. The same operations are available to agents as MCP tools at https://mcp.g1t.sh.",
223 "license": { "name": "MIT", "identifier": "MIT" },
224 },
225 "servers": [{ "url": "https://api.g1t.sh" }],
226 "security": [{ "token": [] }, {}],
227 "tags": [
228 { "name": "Accounts", "description": "Signing in from a tool, and the current user." },
229 { "name": "Repositories" },
230 {
231 "name": "Issues",
232 "description": "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
233 },
234 {
235 "name": "Pull requests",
236 "description": "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.",
237 },
238 { "name": "Sessions", "description": "The record of how a pull request was made." },
239 ],
240 "paths": paths,
241 "components": {
242 "securitySchemes": {
243 "token": {
244 "type": "http",
245 "scheme": "bearer",
246 "description": "An access token, `g1t_…`. Public data needs none.",
247 },
248 },
249 "schemas": {
250 "Error": {
251 "type": "object",
252 "required": ["error"],
253 "properties": {
254 "error": {
255 "type": "object",
256 "required": ["code", "message"],
257 "properties": {
258 "code": {
259 "type": "string",
260 "enum": ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"],
261 },
262 "message": { "type": "string" },
263 },
264 },
265 },
266 },
267 },
268 },
269 })
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn every_route_is_documented_once() {
278 let document = document();
279 let mut ids = Vec::new();
280 for (_, methods) in document["paths"].as_object().unwrap() {
281 for (_, operation) in methods.as_object().unwrap() {
282 ids.push(operation["operationId"].as_str().unwrap().to_owned());
283 }
284 }
285 for op in Op::ALL {
286 assert_eq!(
287 ids.iter().filter(|id| *id == op.name()).count(),
288 1,
289 "{}",
290 op.name()
291 );
292 }
293 }
294
295 #[test]
296 fn path_and_query_inputs_are_not_repeated_in_the_body() {
297 let document = document();
298 let merge = &document["paths"]["/v1/repos/{owner}/{name}/pulls/{number}/merge"]["post"];
299 let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"];
300 assert!(body.get("keep_issue_open").is_some());
301 assert!(body.get("repo").is_none() && body.get("number").is_none());
302 let list = &document["paths"]["/v1/repos"]["get"];
303 assert_eq!(list["parameters"][0]["name"], "q");
304 assert!(list.get("requestBody").is_none());
305 }
306
307 #[test]
308 fn titles_read_as_sentences() {
309 assert_eq!(title(Op::CreateIssue), "Create issue");
310 assert_eq!(title(Op::Whoami), "Get the current user");
311 }
312}