pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/openapi.rs

747 lines29,662 bytesCodeBlame
1//! The OpenAPI document, generated from the same list the routes are.
2//!
3//! The docs site builds its API reference from a copy of this document,
4//! `apps/docs/src/data/openapi.json`. A test keeps the copy current: run
5//! `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi` to rewrite it.
6
7use serde_json::{Map, Value, json};
8
9use crate::operations::Op;
10use crate::rest::{ROUTES, Route};
11
12/// The sections of the API reference: a name, what it covers, and its
13/// operations in the order a reader meets them.
14const SECTIONS: &[(&str, &str, &[Op])] = &[
15 (
16 "Accounts",
17 "Signing in from a tool, and who a token acts as.",
18 &[Op::Whoami],
19 ),
20 (
21 "Workspaces",
22 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
23 &[Op::CreateWorkspace],
24 ),
25 (
26 "Repositories",
27 "A repository, how it handles pull requests, and its timeline.",
28 &[
29 Op::ListRepos,
30 Op::CreateRepo,
31 Op::GetRepo,
32 Op::UpdateRepo,
33 Op::GetRepoSettings,
34 Op::UpdateRepoSettings,
35 Op::ListEvents,
36 ],
37 ),
38 (
39 "Issues",
40 "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
41 &[
42 Op::ListIssues,
43 Op::CreateIssue,
44 Op::GetIssue,
45 Op::UpdateIssue,
46 Op::CloseIssue,
47 Op::ReopenIssue,
48 Op::AssignIssue,
49 Op::AddComment,
50 Op::ListLabels,
51 ],
52 ),
53 (
54 "Plans",
55 "An outcome turned into the issues that would get there, with the order they must merge in.",
56 &[Op::PlanWork, Op::GetPlan, Op::ApplyPlan],
57 ),
58 (
59 "Pull requests",
60 "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.",
61 &[
62 Op::ListPullRequests,
63 Op::CreatePullRequest,
64 Op::GetPullRequest,
65 Op::GetPullRequestChanges,
66 Op::MarkPullRequestReady,
67 Op::ReviewPullRequest,
68 Op::MergePullRequest,
69 Op::ClosePullRequest,
70 Op::GetMergeQueue,
71 Op::MessageAgent,
72 Op::AnswerMessage,
73 Op::TakeMessages,
74 ],
75 ),
76 (
77 "Sessions",
78 "The record of how a pull request was made: prompts, reasoning and the tools that ran.",
79 &[Op::ReadSession, Op::RecordSession],
80 ),
81 (
82 "Memory",
83 "What agents and people learned that the next agent should know, for one project or across a workspace. Members and g1t's agents only; never a secret.",
84 &[Op::Remember, Op::Recall],
85 ),
86 (
87 "Search",
88 "One search across all of g1t: repositories, code, issues, pull requests, people and workspaces. Public content for everyone, and private content in workspaces you belong to.",
89 &[Op::Search],
90 ),
91 (
92 "Context",
93 "A workspace's context hub: a catalog of what it builds and runs, built from its repositories, deployments and integrations, and one search across the catalog, docs, issues, pull requests and memory.",
94 &[Op::SearchContext, Op::GetEntity],
95 ),
96 (
97 "Actions",
98 "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.",
99 &[
100 Op::ListWorkflows,
101 Op::ListWorkflowRuns,
102 Op::GetWorkflowRun,
103 Op::GetJobLogs,
104 Op::DispatchWorkflow,
105 Op::CancelWorkflowRun,
106 Op::RerunWorkflowRun,
107 Op::UpdateWorkflow,
108 ],
109 ),
110 (
111 "Secrets and variables",
112 "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.",
113 &[
114 Op::ListActionsSecrets,
115 Op::SetActionsSecret,
116 Op::DeleteActionsSecret,
117 Op::ListActionsVariables,
118 Op::SetActionsVariable,
119 Op::DeleteActionsVariable,
120 ],
121 ),
122 (
123 "Webhooks",
124 "Signed HTTPS requests sent to your own address as things happen, for a repository or a whole workspace.",
125 &[
126 Op::ListWebhooks,
127 Op::CreateWebhook,
128 Op::UpdateWebhook,
129 Op::DeleteWebhook,
130 Op::PingWebhook,
131 Op::ListWebhookDeliveries,
132 Op::RedeliverWebhook,
133 ],
134 ),
135 (
136 "Integrations",
137 "A workspace's connections to outside systems: model providers, alert sources and issue trackers.",
138 &[
139 Op::ListIntegrations,
140 Op::ConnectIntegration,
141 Op::DisconnectIntegration,
142 Op::TestIntegration,
143 Op::GetModelRoutes,
144 Op::SetModelRoutes,
145 Op::GetContext,
146 Op::ImportIssue,
147 ],
148 ),
149];
150
151/// The section of the API reference an operation is listed under.
152fn tag(op: Op) -> &'static str {
153 SECTIONS
154 .iter()
155 .find(|(_, _, ops)| ops.contains(&op))
156 .map_or("Repositories", |(name, _, _)| name)
157}
158
159/// What an operation's page is called, as a short sentence.
160fn title(op: Op) -> &'static str {
161 match op {
162 Op::Whoami => "Get the current user",
163 Op::CreateWorkspace => "Create a workspace",
164 Op::ListRepos => "List repositories",
165 Op::GetRepo => "Get a repository",
166 Op::CreateRepo => "Create a repository",
167 Op::UpdateRepo => "Update a repository",
168 Op::GetRepoSettings => "Get repository settings",
169 Op::UpdateRepoSettings => "Update repository settings",
170 Op::GetMergeQueue => "Get the merge queue",
171 Op::MessageAgent => "Message an agent",
172 Op::AnswerMessage => "Answer a message",
173 Op::TakeMessages => "Take new messages",
174 Op::Remember => "Remember something",
175 Op::Recall => "Recall memory",
176 Op::SearchContext => "Search the context hub",
177 Op::GetEntity => "Get a catalog entry",
178 Op::Search => "Search g1t",
179 Op::ListIssues => "List issues",
180 Op::GetIssue => "Get an issue",
181 Op::CreateIssue => "Create an issue",
182 Op::UpdateIssue => "Update an issue",
183 Op::CloseIssue => "Close an issue",
184 Op::ReopenIssue => "Reopen an issue",
185 Op::AssignIssue => "Assign an issue to the g1t agent",
186 Op::PlanWork => "Plan work",
187 Op::GetPlan => "Get a plan",
188 Op::ApplyPlan => "Apply a plan",
189 Op::ListLabels => "List labels",
190 Op::AddComment => "Add a comment",
191 Op::ReviewPullRequest => "Review a pull request",
192 Op::ListPullRequests => "List pull requests",
193 Op::GetPullRequest => "Get a pull request",
194 Op::CreatePullRequest => "Create a pull request",
195 Op::RecordSession => "Record session entries",
196 Op::ReadSession => "Read a session",
197 Op::MarkPullRequestReady => "Mark a pull request ready",
198 Op::ClosePullRequest => "Close a pull request",
199 Op::GetPullRequestChanges => "Get a pull request's changes",
200 Op::MergePullRequest => "Merge a pull request",
201 Op::ListEvents => "List repository events",
202 Op::ListIntegrations => "List integrations",
203 Op::ConnectIntegration => "Connect an integration",
204 Op::DisconnectIntegration => "Disconnect an integration",
205 Op::TestIntegration => "Test an integration",
206 Op::GetContext => "Look up a ticket",
207 Op::ImportIssue => "Import an issue",
208 Op::GetModelRoutes => "Get model routes",
209 Op::SetModelRoutes => "Set model routes",
210 Op::ListWebhooks => "List webhooks",
211 Op::CreateWebhook => "Create a webhook",
212 Op::UpdateWebhook => "Update a webhook",
213 Op::DeleteWebhook => "Delete a webhook",
214 Op::PingWebhook => "Ping a webhook",
215 Op::ListWebhookDeliveries => "List webhook deliveries",
216 Op::RedeliverWebhook => "Redeliver a webhook delivery",
217 Op::ListWorkflows => "List workflows",
218 Op::ListWorkflowRuns => "List workflow runs",
219 Op::GetWorkflowRun => "Get a workflow run",
220 Op::GetJobLogs => "Get a job's log",
221 Op::DispatchWorkflow => "Run a workflow",
222 Op::CancelWorkflowRun => "Cancel a workflow run",
223 Op::RerunWorkflowRun => "Re-run a workflow run",
224 Op::UpdateWorkflow => "Turn a workflow on or off",
225 Op::ListActionsSecrets => "List secrets",
226 Op::SetActionsSecret => "Set a secret",
227 Op::DeleteActionsSecret => "Delete a secret",
228 Op::ListActionsVariables => "List variables",
229 Op::SetActionsVariable => "Set a variable",
230 Op::DeleteActionsVariable => "Delete a variable",
231 }
232}
233
234/// Whether an operation can be refused with `402 payment_required`: the
235/// ones that start an agent, when the workspace has no credit.
236fn may_need_payment(op: Op) -> bool {
237 matches!(op, Op::AssignIssue | Op::PlanWork | Op::ApplyPlan)
238}
239
240/// What the reference says beyond each operation's own description, keyed
241/// by operation id, written by hand from what the services return: `notes`
242/// (Markdown, added to the description) and example `params` (path),
243/// `query`, `request` (body) and `response`.
244const REFERENCE: &str = include_str!("reference.json");
245
246fn examples() -> Map<String, Value> {
247 match serde_json::from_str(REFERENCE) {
248 Ok(Value::Object(examples)) => examples,
249 _ => Map::new(),
250 }
251}
252
253/// `/repos/:owner/:name` as OpenAPI writes it: `/repos/{owner}/{name}`.
254fn openapi_path(route: &Route) -> String {
255 route
256 .path
257 .split('/')
258 .map(|segment| match segment.strip_prefix(':') {
259 Some(name) => format!("{{{name}}}"),
260 None => segment.to_owned(),
261 })
262 .collect::<Vec<_>>()
263 .join("/")
264}
265
266fn error_response(description: &str) -> Value {
267 json!({
268 "description": description,
269 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
270 })
271}
272
273/// A parameter in the path or the query, described by the operation's
274/// input schema where it has the same name.
275fn parameter(name: &str, place: &str, required: bool, schema: Option<&Value>) -> Value {
276 let mut schema = schema.cloned().unwrap_or_else(|| json!({ "type": "string" }));
277 let description = match name {
278 "owner" => Some(Value::from("The workspace that owns the repository.")),
279 "name" => Some(Value::from("The repository's name.")),
280 _ => schema.as_object_mut().and_then(|schema| schema.remove("description")),
281 };
282 let mut parameter = json!({
283 "name": name,
284 "in": place,
285 "required": required,
286 "schema": schema,
287 });
288 if let Some(description) = description {
289 parameter["description"] = description;
290 }
291 parameter
292}
293
294/// The operation id of a route. An operation reached at a workspace's
295/// address as well as a repository's is documented once for each, with its
296/// own id; GitHub's alternative addresses for one operation keep GitHub's
297/// names.
298fn operation_id(route: &Route) -> String {
299 let op = route.op;
300 let base = match (route.method, route.path.rsplit('/').next().unwrap_or_default()) {
301 ("PUT", "enable") => "enable_workflow".to_owned(),
302 ("PUT", "disable") => "disable_workflow".to_owned(),
303 ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(),
304 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
305 ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(),
306 _ => op.name().to_owned(),
307 };
308 if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
309 format!("{base}_for_workspace")
310 } else {
311 base
312 }
313}
314
315/// The summary of a route: its operation's title, or for one of GitHub's
316/// alternative addresses, what that address does.
317fn summary(route: &Route, id: &str) -> String {
318 let base = match id.trim_end_matches("_for_workspace") {
319 "enable_workflow" => "Turn a workflow on",
320 "disable_workflow" => "Turn a workflow off",
321 "rerun_failed_jobs" => "Re-run failed jobs",
322 "update_actions_variable" => "Update a variable",
323 "list_runs_of_workflow" => "List a workflow's runs",
324 _ => title(route.op),
325 };
326 if id.ends_with("_for_workspace") {
327 format!("{base} for a workspace")
328 } else {
329 base.to_owned()
330 }
331}
332
333fn operation(route: &Route) -> Value {
334 let op = route.op;
335 let path_params: Vec<&str> = route.params().collect();
336 // `owner` and `name` in the path stand for the operation's `repo` input.
337 let covered = |name: &str| name == "repo" || path_params.contains(&name);
338 let all_properties = op.properties();
339 let mut properties = all_properties.clone();
340 properties.retain(|name, _| !covered(name));
341 let required: Vec<String> = op
342 .required()
343 .into_iter()
344 .filter(|name| !covered(name))
345 .collect();
346
347 let mut parameters: Vec<Value> = path_params
348 .iter()
349 .map(|name| parameter(name, "path", true, all_properties.get(*name)))
350 .collect();
351 let mut body = Value::Null;
352 if route.method == "GET" {
353 for (name, key) in route.query {
354 parameters.push(parameter(
355 name,
356 "query",
357 required.iter().any(|required| required == key),
358 properties.get(*key),
359 ));
360 }
361 } else if !properties.is_empty() {
362 let mut schema = json!({ "type": "object", "properties": properties });
363 if !required.is_empty() {
364 schema["required"] = json!(required);
365 }
366 body = json!({
367 "required": !required.is_empty(),
368 "content": { "application/json": { "schema": schema } },
369 });
370 }
371
372 let id = operation_id(route);
373 let mut responses = Map::new();
374 responses.insert(
375 "200".into(),
376 json!({
377 "description": "Success.",
378 "content": { "application/json": { "schema": {} } },
379 }),
380 );
381 responses.insert(
382 "401".into(),
383 error_response("A token is required, or the one sent is not valid."),
384 );
385 if may_need_payment(op) {
386 responses.insert(
387 "402".into(),
388 error_response("The workspace has no agent credit."),
389 );
390 }
391 responses.insert("403".into(), error_response("Signed in, but not allowed to do this."));
392 if !matches!(op, Op::Whoami | Op::ListRepos | Op::Search) {
393 responses.insert("404".into(), error_response("It does not exist, or you cannot see it."));
394 }
395 if route.method != "GET" {
396 responses.insert(
397 "409".into(),
398 error_response("The request conflicts with the current state."),
399 );
400 }
401 if op != Op::Whoami {
402 responses.insert("422".into(), error_response("The input is not valid."));
403 }
404 // Public data can be read without a token; everything else needs one.
405 let security = if op.needs_user() {
406 json!([{ "token": [] }])
407 } else {
408 json!([{ "token": [] }, {}])
409 };
410 let mut described = json!({
411 "operationId": id,
412 "tags": [tag(op)],
413 "summary": summary(route, &id),
414 "description": op.description(),
415 "x-mcp-tool": op.name(),
416 "security": security,
417 "parameters": parameters,
418 "responses": responses,
419 });
420 if !body.is_null() {
421 described["requestBody"] = body;
422 }
423 described
424}
425
426/// Entries for device sign-in, which is not an operation.
427fn onboarding() -> Map<String, Value> {
428 let paths = json!({
429 "/device/code": {
430 "post": {
431 "operationId": "device_code",
432 "tags": ["Accounts"],
433 "summary": "Start signing in",
434 "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`.",
435 "security": [],
436 "requestBody": {
437 "content": { "application/json": { "schema": {
438 "type": "object",
439 "properties": {
440 "client_name": {
441 "type": "string",
442 "description": "What is asking, shown to the person approving. For example, Claude Code.",
443 },
444 },
445 } } },
446 },
447 "responses": { "200": {
448 "description": "The codes for this sign-in.",
449 "content": { "application/json": { "schema": {
450 "type": "object",
451 "properties": {
452 "device_code": { "type": "string", "description": "Secret. Send it to /device/token." },
453 "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." },
454 "verification_uri": { "type": "string" },
455 "verification_uri_complete": {
456 "type": "string",
457 "description": "The link to give the person; it carries the code.",
458 },
459 "expires_in": { "type": "integer", "description": "Seconds until the codes expire." },
460 "interval": { "type": "integer", "description": "Seconds to wait between polls." },
461 },
462 } } },
463 } },
464 },
465 },
466 "/device/token": {
467 "post": {
468 "operationId": "device_token",
469 "tags": ["Accounts"],
470 "summary": "Finish signing in",
471 "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.",
472 "security": [],
473 "requestBody": {
474 "required": true,
475 "content": { "application/json": { "schema": {
476 "type": "object",
477 "required": ["device_code"],
478 "properties": { "device_code": { "type": "string" } },
479 } } },
480 },
481 "responses": { "200": {
482 "description": "The state of the sign-in.",
483 "content": { "application/json": { "schema": {
484 "type": "object",
485 "required": ["status"],
486 "properties": {
487 "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] },
488 "token": { "type": "string", "description": "Present when approved." },
489 "username": { "type": "string" },
490 "verified": {
491 "type": "boolean",
492 "description": "Whether the account's email is confirmed.",
493 },
494 },
495 } } },
496 } },
497 },
498 },
499 });
500 match paths {
501 Value::Object(paths) => paths,
502 _ => Map::new(),
503 }
504}
505
506
507/// Puts each operation's examples, where it has them, into its request
508/// and response. Path and query values go under `x-example-params` and
509/// `x-example-query`, which tools that build a request can use.
510fn attach_examples(paths: &mut Map<String, Value>) {
511 let examples = examples();
512 for methods in paths.values_mut() {
513 let Some(methods) = methods.as_object_mut() else { continue };
514 for operation in methods.values_mut() {
515 let id = operation["operationId"].as_str().unwrap_or_default().to_owned();
516 let tool = operation["x-mcp-tool"].as_str().unwrap_or_default().to_owned();
517 let Some(example) = examples.get(&id).or_else(|| examples.get(&tool)) else {
518 continue;
519 };
520 if let Some(notes) = example.get("notes").and_then(Value::as_str) {
521 let description = operation["description"].as_str().unwrap_or_default();
522 operation["description"] = json!(format!("{description}\n\n{notes}"));
523 }
524 if let Some(response) = example.get("response") {
525 let content = &mut operation["responses"]["200"]["content"]["application/json"];
526 if content.is_object() {
527 content["example"] = response.clone();
528 }
529 }
530 if let Some(request) = example.get("request") {
531 let content = &mut operation["requestBody"]["content"]["application/json"];
532 if content.is_object() {
533 content["example"] = request.clone();
534 }
535 }
536 for (key, extension) in [("params", "x-example-params"), ("query", "x-example-query")] {
537 if let Some(values) = example.get(key) {
538 operation[extension] = values.clone();
539 }
540 }
541 }
542 }
543}
544
545pub fn document() -> Value {
546 let mut paths = onboarding();
547 for route in ROUTES {
548 let entry = paths
549 .entry(openapi_path(route))
550 .or_insert_with(|| json!({}));
551 entry[route.method.to_lowercase()] = operation(route);
552 }
553 attach_examples(&mut paths);
554 let tags: Vec<Value> = SECTIONS
555 .iter()
556 .map(|(name, description, ops)| {
557 json!({
558 "name": name,
559 "description": description,
560 // The section's operations in reading order, by MCP tool name.
561 "x-tools": ops.iter().map(|op| op.name()).collect::<Vec<_>>(),
562 })
563 })
564 .collect();
565 let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid"];
566 json!({
567 "openapi": "3.1.0",
568 "info": {
569 "title": "g1t API",
570 "version": "1",
571 "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. Every name in a request or response body is `snake_case`; names you chose, such as a workflow's inputs or a secret's name, are returned as you wrote them.",
572 "license": { "name": "MIT", "identifier": "MIT" },
573 },
574 "servers": [{ "url": "https://api.g1t.sh" }],
575 "security": [{ "token": [] }, {}],
576 "tags": tags,
577 "paths": paths,
578 "components": {
579 "securitySchemes": {
580 "token": {
581 "type": "http",
582 "scheme": "bearer",
583 "description": "An access token, `g1t_…`. Public data needs none.",
584 },
585 },
586 "schemas": {
587 "Error": {
588 "type": "object",
589 "required": ["error"],
590 "properties": {
591 "error": {
592 "type": "object",
593 "required": ["code", "message"],
594 "properties": {
595 "code": { "type": "string", "enum": codes },
596 "message": { "type": "string" },
597 },
598 },
599 },
600 },
601 },
602 },
603 })
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn every_route_is_documented_once() {
612 let document = document();
613 let mut ids = Vec::new();
614 for (_, methods) in document["paths"].as_object().unwrap() {
615 for (_, operation) in methods.as_object().unwrap() {
616 ids.push(operation["operationId"].as_str().unwrap().to_owned());
617 }
618 }
619 for op in Op::ALL {
620 assert_eq!(
621 ids.iter().filter(|id| *id == op.name()).count(),
622 1,
623 "{}",
624 op.name()
625 );
626 }
627 let mut unique = ids.clone();
628 unique.sort();
629 unique.dedup();
630 assert_eq!(unique.len(), ids.len(), "operation ids repeat");
631 }
632
633 #[test]
634 fn path_and_query_inputs_are_not_repeated_in_the_body() {
635 let document = document();
636 let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"];
637 let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"];
638 assert!(body.get("keep_issue_open").is_some());
639 assert!(body.get("repo").is_none() && body.get("number").is_none());
640 let list = &document["paths"]["/repos"]["get"];
641 assert_eq!(list["parameters"][0]["name"], "q");
642 assert!(list.get("requestBody").is_none());
643 }
644
645 #[test]
646 fn every_operation_is_in_one_section() {
647 for op in Op::ALL {
648 let sections = SECTIONS
649 .iter()
650 .filter(|(_, _, ops)| ops.contains(&op))
651 .count();
652 assert_eq!(sections, 1, "{}", op.name());
653 }
654 }
655
656 #[test]
657 fn titles_read_as_sentences() {
658 assert_eq!(title(Op::CreateIssue), "Create an issue");
659 assert_eq!(title(Op::Whoami), "Get the current user");
660 }
661
662 #[test]
663 fn every_operation_has_an_example_response() {
664 let examples = examples();
665 assert!(!examples.is_empty(), "reference.json does not parse");
666 let document = document();
667 let mut known = Vec::new();
668 for (path, methods) in document["paths"].as_object().unwrap() {
669 for (method, operation) in methods.as_object().unwrap() {
670 known.push(operation["operationId"].as_str().unwrap().to_owned());
671 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
672 assert!(!example.is_null(), "{method} {path} has no example response");
673 }
674 }
675 for id in examples.keys() {
676 assert!(known.contains(id), "reference.json names {id}, which is not an operation");
677 }
678 }
679
680 #[test]
681 fn example_requests_send_only_what_the_body_takes() {
682 let document = document();
683 for (path, methods) in document["paths"].as_object().unwrap() {
684 for (method, operation) in methods.as_object().unwrap() {
685 let content = &operation["requestBody"]["content"]["application/json"];
686 let Some(example) = content["example"].as_object() else { continue };
687 let properties = &content["schema"]["properties"];
688 for key in example.keys() {
689 assert!(!properties[key].is_null(), "{method} {path}: {key} is not in the body");
690 }
691 }
692 }
693 }
694
695 /// The docs site's copy of the document. Run with `G1T_WRITE_OPENAPI=1`
696 /// to rewrite it after changing an operation.
697 #[test]
698 fn the_docs_copy_is_current() {
699 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../docs/src/data/openapi.json");
700 let current = serde_json::to_string_pretty(&document()).unwrap() + "\n";
701 if std::env::var_os("G1T_WRITE_OPENAPI").is_some() {
702 std::fs::write(path, &current).unwrap();
703 return;
704 }
705 let copy = std::fs::read_to_string(path).unwrap_or_default().replace("\r\n", "\n");
706 assert!(
707 copy == current,
708 "apps/docs/src/data/openapi.json is out of date: run G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi"
709 );
710 }
711
712 /// The reference shows responses as they are sent: `snake_case`.
713 #[test]
714 fn example_responses_are_snake_case() {
715 let document = document();
716 for (path, methods) in document["paths"].as_object().unwrap() {
717 for (method, operation) in methods.as_object().unwrap() {
718 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
719 let leaked = g1t_kit::wire::camel_case_keys(example);
720 assert!(leaked.is_empty(), "{method} {path} shows {leaked:?}");
721 }
722 }
723 }
724
725 /// Examples never hold anything that reads as a real credential, which
726 /// secret scanners rightly flag in a public repository: they end in `…`
727 /// after the prefix, as `whsec_…` and `g1t_…` do.
728 #[test]
729 fn examples_hold_no_real_looking_secrets() {
730 let prefixes = ["whsec_", "g1t_", "sk_live_", "sk_test_", "ghp_", "github_pat_", "xoxb-", "AKIA"];
731 for (line, text) in REFERENCE.lines().enumerate() {
732 for prefix in prefixes {
733 let mut rest = text;
734 while let Some(at) = rest.find(prefix) {
735 let after = &rest[at + prefix.len()..];
736 let run = after.chars().take_while(|c| c.is_ascii_alphanumeric()).count();
737 assert!(
738 run < 12,
739 "reference.json line {}: `{prefix}` followed by {run} characters reads as a real secret; write `{prefix}…`",
740 line + 1
741 );
742 rest = after;
743 }
744 }
745 }
746 }
747}