pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/openapi.rs

314 lines12,012 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("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".
30fn 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}`.
43fn 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
55fn error_response(description: &str) -> Value {
56 json!({
57 "description": description,
58 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
59 })
60}
61
62fn 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.
132fn 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
211pub 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)]
275mod 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}