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