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