| 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("webhook") { |
| 12 | "Webhooks" |
| 13 | } else if name.contains("workflow") || name.contains("actions_") || name == "get_job_logs" { |
| 14 | "Actions" |
| 15 | } else if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext { |
| 16 | "Integrations" |
| 17 | } else if op == Op::Whoami || name.contains("workspace") { |
| 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 | |
| 46 | /// `/repos/:owner/:name` as OpenAPI writes it: `/repos/{owner}/{name}`. |
| 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 | |
| 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 | // GitHub's alternative addresses for one operation keep GitHub's names. |
| 114 | let base = match (route.method, route.path.rsplit('/').next().unwrap_or_default()) { |
| 115 | ("PUT", "enable") => "enable_workflow".to_owned(), |
| 116 | ("PUT", "disable") => "disable_workflow".to_owned(), |
| 117 | ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(), |
| 118 | ("PATCH", ":setting") => "update_actions_variable".to_owned(), |
| 119 | ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(), |
| 120 | _ => op.name().to_owned(), |
| 121 | }; |
| 122 | let id = if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) { |
| 123 | format!("{base}_for_workspace") |
| 124 | } else { |
| 125 | base |
| 126 | }; |
| 127 | let mut described = json!({ |
| 128 | "operationId": id, |
| 129 | "tags": [tag(op)], |
| 130 | "summary": title(op), |
| 131 | "description": op.description(), |
| 132 | "parameters": parameters, |
| 133 | "responses": { |
| 134 | "200": { |
| 135 | "description": "Success.", |
| 136 | "content": { "application/json": { "schema": {} } }, |
| 137 | }, |
| 138 | "401": error_response("A token is required, or the one sent is not valid."), |
| 139 | "403": error_response("Signed in, but not allowed to do this."), |
| 140 | "404": error_response("It does not exist, or you cannot see it."), |
| 141 | "409": error_response("The request conflicts with the current state."), |
| 142 | "422": error_response("The input is not valid."), |
| 143 | }, |
| 144 | }); |
| 145 | if !body.is_null() { |
| 146 | described["requestBody"] = body; |
| 147 | } |
| 148 | described |
| 149 | } |
| 150 | |
| 151 | /// Entries for device sign-in, which is not an operation. |
| 152 | fn onboarding() -> Map<String, Value> { |
| 153 | let paths = json!({ |
| 154 | "/device/code": { |
| 155 | "post": { |
| 156 | "operationId": "device_code", |
| 157 | "tags": ["Accounts"], |
| 158 | "summary": "Start signing in", |
| 159 | "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`.", |
| 160 | "security": [], |
| 161 | "requestBody": { |
| 162 | "content": { "application/json": { "schema": { |
| 163 | "type": "object", |
| 164 | "properties": { |
| 165 | "client_name": { |
| 166 | "type": "string", |
| 167 | "description": "What is asking, shown to the person approving. For example, Claude Code.", |
| 168 | }, |
| 169 | }, |
| 170 | } } }, |
| 171 | }, |
| 172 | "responses": { "200": { |
| 173 | "description": "The codes for this sign-in.", |
| 174 | "content": { "application/json": { "schema": { |
| 175 | "type": "object", |
| 176 | "properties": { |
| 177 | "device_code": { "type": "string", "description": "Secret. Send it to /device/token." }, |
| 178 | "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." }, |
| 179 | "verification_uri": { "type": "string" }, |
| 180 | "verification_uri_complete": { |
| 181 | "type": "string", |
| 182 | "description": "The link to give the person; it carries the code.", |
| 183 | }, |
| 184 | "expires_in": { "type": "integer", "description": "Seconds until the codes expire." }, |
| 185 | "interval": { "type": "integer", "description": "Seconds to wait between polls." }, |
| 186 | }, |
| 187 | } } }, |
| 188 | } }, |
| 189 | }, |
| 190 | }, |
| 191 | "/device/token": { |
| 192 | "post": { |
| 193 | "operationId": "device_token", |
| 194 | "tags": ["Accounts"], |
| 195 | "summary": "Finish signing in", |
| 196 | "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.", |
| 197 | "security": [], |
| 198 | "requestBody": { |
| 199 | "required": true, |
| 200 | "content": { "application/json": { "schema": { |
| 201 | "type": "object", |
| 202 | "required": ["device_code"], |
| 203 | "properties": { "device_code": { "type": "string" } }, |
| 204 | } } }, |
| 205 | }, |
| 206 | "responses": { "200": { |
| 207 | "description": "The state of the sign-in.", |
| 208 | "content": { "application/json": { "schema": { |
| 209 | "type": "object", |
| 210 | "required": ["status"], |
| 211 | "properties": { |
| 212 | "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] }, |
| 213 | "token": { "type": "string", "description": "Present when approved." }, |
| 214 | "username": { "type": "string" }, |
| 215 | "verified": { |
| 216 | "type": "boolean", |
| 217 | "description": "Whether the account's email is confirmed.", |
| 218 | }, |
| 219 | }, |
| 220 | } } }, |
| 221 | } }, |
| 222 | }, |
| 223 | }, |
| 224 | }); |
| 225 | match paths { |
| 226 | Value::Object(paths) => paths, |
| 227 | _ => Map::new(), |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | pub fn document() -> Value { |
| 232 | let mut paths = onboarding(); |
| 233 | for route in ROUTES { |
| 234 | let entry = paths |
| 235 | .entry(openapi_path(route)) |
| 236 | .or_insert_with(|| json!({})); |
| 237 | entry[route.method.to_lowercase()] = operation(route); |
| 238 | } |
| 239 | json!({ |
| 240 | "openapi": "3.1.0", |
| 241 | "info": { |
| 242 | "title": "g1t API", |
| 243 | "version": "1", |
| 244 | "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.", |
| 245 | "license": { "name": "MIT", "identifier": "MIT" }, |
| 246 | }, |
| 247 | "servers": [{ "url": "https://api.g1t.sh" }], |
| 248 | "security": [{ "token": [] }, {}], |
| 249 | "tags": [ |
| 250 | { "name": "Accounts", "description": "Signing in from a tool, and the current user." }, |
| 251 | { "name": "Repositories" }, |
| 252 | { |
| 253 | "name": "Issues", |
| 254 | "description": "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.", |
| 255 | }, |
| 256 | { |
| 257 | "name": "Pull requests", |
| 258 | "description": "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.", |
| 259 | }, |
| 260 | { "name": "Sessions", "description": "The record of how a pull request was made." }, |
| 261 | ], |
| 262 | "paths": paths, |
| 263 | "components": { |
| 264 | "securitySchemes": { |
| 265 | "token": { |
| 266 | "type": "http", |
| 267 | "scheme": "bearer", |
| 268 | "description": "An access token, `g1t_…`. Public data needs none.", |
| 269 | }, |
| 270 | }, |
| 271 | "schemas": { |
| 272 | "Error": { |
| 273 | "type": "object", |
| 274 | "required": ["error"], |
| 275 | "properties": { |
| 276 | "error": { |
| 277 | "type": "object", |
| 278 | "required": ["code", "message"], |
| 279 | "properties": { |
| 280 | "code": { |
| 281 | "type": "string", |
| 282 | "enum": ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"], |
| 283 | }, |
| 284 | "message": { "type": "string" }, |
| 285 | }, |
| 286 | }, |
| 287 | }, |
| 288 | }, |
| 289 | }, |
| 290 | }, |
| 291 | }) |
| 292 | } |
| 293 | |
| 294 | #[cfg(test)] |
| 295 | mod tests { |
| 296 | use super::*; |
| 297 | |
| 298 | #[test] |
| 299 | fn every_route_is_documented_once() { |
| 300 | let document = document(); |
| 301 | let mut ids = Vec::new(); |
| 302 | for (_, methods) in document["paths"].as_object().unwrap() { |
| 303 | for (_, operation) in methods.as_object().unwrap() { |
| 304 | ids.push(operation["operationId"].as_str().unwrap().to_owned()); |
| 305 | } |
| 306 | } |
| 307 | for op in Op::ALL { |
| 308 | assert_eq!( |
| 309 | ids.iter().filter(|id| *id == op.name()).count(), |
| 310 | 1, |
| 311 | "{}", |
| 312 | op.name() |
| 313 | ); |
| 314 | } |
| 315 | let mut unique = ids.clone(); |
| 316 | unique.sort(); |
| 317 | unique.dedup(); |
| 318 | assert_eq!(unique.len(), ids.len(), "operation ids repeat"); |
| 319 | } |
| 320 | |
| 321 | #[test] |
| 322 | fn path_and_query_inputs_are_not_repeated_in_the_body() { |
| 323 | let document = document(); |
| 324 | let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"]; |
| 325 | let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"]; |
| 326 | assert!(body.get("keep_issue_open").is_some()); |
| 327 | assert!(body.get("repo").is_none() && body.get("number").is_none()); |
| 328 | let list = &document["paths"]["/repos"]["get"]; |
| 329 | assert_eq!(list["parameters"][0]["name"], "q"); |
| 330 | assert!(list.get("requestBody").is_none()); |
| 331 | } |
| 332 | |
| 333 | #[test] |
| 334 | fn titles_read_as_sentences() { |
| 335 | assert_eq!(title(Op::CreateIssue), "Create issue"); |
| 336 | assert_eq!(title(Op::Whoami), "Get the current user"); |
| 337 | } |
| 338 | } |