pr_01m47d15m3e54sn21z27rpy5n9/apps/api/src/openapi.rs

338 lines13,261 bytesCodeBlame
1//! 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 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".
34fn 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}`.
47fn 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
59fn error_response(description: &str) -> Value {
60 json!({
61 "description": description,
62 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
63 })
64}
65
66fn 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.
152fn 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
231pub 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)]
295mod 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}