| 1 | //! Every response the REST routes give, run through the converter the API |
| 2 | //! sends them with, checked for `camelCase` that would leak out. |
| 3 | //! |
| 4 | //! The samples are the reference's example responses, put back into the |
| 5 | //! `camelCase` the services send (as serde's `rename_all` writes it) and, |
| 6 | //! where an operation returns a contract type, decoded into that type and |
| 7 | //! encoded again, so that every field the type has is sent, not only the |
| 8 | //! ones an example shows. |
| 9 | |
| 10 | use g1t_contracts::{actions, integrations, repos, webhooks, work}; |
| 11 | use g1t_kit::wire::{self, USER_KEYED}; |
| 12 | use serde::Serialize; |
| 13 | use serde::de::DeserializeOwned; |
| 14 | use serde_json::{Map, Value, json}; |
| 15 | |
| 16 | use crate::openapi::document; |
| 17 | use crate::operations::Op; |
| 18 | |
| 19 | /// A key as `#[serde(rename_all = "camelCase")]` writes it. |
| 20 | fn camel_key(key: &str) -> String { |
| 21 | let mut out = String::with_capacity(key.len()); |
| 22 | let mut upper = false; |
| 23 | for c in key.chars() { |
| 24 | if c == '_' { |
| 25 | upper = true; |
| 26 | } else if upper { |
| 27 | out.extend(c.to_uppercase()); |
| 28 | upper = false; |
| 29 | } else { |
| 30 | out.push(c); |
| 31 | } |
| 32 | } |
| 33 | out |
| 34 | } |
| 35 | |
| 36 | /// A response as the services send it: `camelCase`, but for the maps the |
| 37 | /// converter passes through, which are data. |
| 38 | fn as_services_send(value: &Value) -> Value { |
| 39 | match value { |
| 40 | Value::Object(fields) => { |
| 41 | let mut out = Map::new(); |
| 42 | for (key, value) in fields { |
| 43 | let user_keyed = value.is_object() |
| 44 | && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_")); |
| 45 | let value = if user_keyed { value.clone() } else { as_services_send(value) }; |
| 46 | // A `by_…` map keeps its name in the converter's spelling. |
| 47 | let key = if key.starts_with("by_") { key.clone() } else { camel_key(key) }; |
| 48 | out.insert(key, value); |
| 49 | } |
| 50 | Value::Object(out) |
| 51 | } |
| 52 | Value::Array(items) => Value::Array(items.iter().map(as_services_send).collect()), |
| 53 | other => other.clone(), |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// `value` decoded as `T` and encoded again, as the service would send it. |
| 58 | fn through<T: DeserializeOwned + Serialize>(op: Op, value: Value) -> Value { |
| 59 | let decoded: T = serde_json::from_value(value) |
| 60 | .unwrap_or_else(|error| panic!("{}: the example is not a {}: {error}", op.name(), std::any::type_name::<T>())); |
| 61 | serde_json::to_value(decoded).unwrap() |
| 62 | } |
| 63 | |
| 64 | /// What the service behind an operation sends, from its example. |
| 65 | fn sample(op: Op, example: &Value) -> Value { |
| 66 | let sent = as_services_send(example); |
| 67 | match op { |
| 68 | Op::ListRepos => through::<Vec<repos::Repo>>(op, sent), |
| 69 | Op::GetRepo | Op::CreateRepo | Op::UpdateRepo => through::<repos::Repo>(op, sent), |
| 70 | Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent), |
| 71 | Op::GetMergeQueue => through::<work::QueueView>(op, sent), |
| 72 | Op::ListIssues => through::<Vec<work::Issue>>(op, sent), |
| 73 | Op::CreateIssue | Op::UpdateIssue | Op::CloseIssue | Op::ReopenIssue => { |
| 74 | through::<work::Issue>(op, sent) |
| 75 | } |
| 76 | Op::GetIssue => through::<work::IssueDetail>(op, sent), |
| 77 | Op::ListPullRequests => through::<Vec<work::Pull>>(op, sent), |
| 78 | Op::GetPullRequest => through::<work::PullDetail>(op, sent), |
| 79 | Op::MarkPullRequestReady | Op::ClosePullRequest | Op::MergePullRequest | Op::AssignIssue => { |
| 80 | through::<work::Pull>(op, sent) |
| 81 | } |
| 82 | Op::ListWorkflows => through::<Vec<actions::Workflow>>(op, sent), |
| 83 | Op::ListWorkflowRuns => through::<Vec<actions::WorkflowRun>>(op, sent), |
| 84 | Op::GetWorkflowRun => through::<actions::RunDetail>(op, sent), |
| 85 | Op::GetJobLogs => through::<actions::JobLog>(op, sent), |
| 86 | Op::DispatchWorkflow | Op::CancelWorkflowRun | Op::RerunWorkflowRun => { |
| 87 | through::<actions::WorkflowRun>(op, sent) |
| 88 | } |
| 89 | Op::ListActionsSecrets | Op::ListActionsVariables => through::<Vec<actions::Setting>>(op, sent), |
| 90 | Op::ListWebhooks => through::<Vec<webhooks::Hook>>(op, sent), |
| 91 | Op::ListIntegrations => through::<Vec<integrations::Connection>>(op, sent), |
| 92 | Op::GetModelRoutes | Op::SetModelRoutes => through::<Vec<integrations::ModelRoute>>(op, sent), |
| 93 | Op::ListEvents => through::<Vec<g1t_contracts::events::Event>>(op, sent), |
| 94 | Op::Whoami => through::<g1t_contracts::User>(op, sent), |
| 95 | _ => sent, |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// Every key of `example`, as paths, outside the maps passed through. |
| 100 | fn paths(value: &Value, path: &str, out: &mut Vec<String>) { |
| 101 | match value { |
| 102 | Value::Object(fields) => { |
| 103 | for (key, value) in fields { |
| 104 | let here = format!("{path}.{key}"); |
| 105 | out.push(here.clone()); |
| 106 | let user_keyed = value.is_object() |
| 107 | && (USER_KEYED.contains(&key.as_str()) || key.starts_with("by_")); |
| 108 | if !user_keyed { |
| 109 | paths(value, &here, out); |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | Value::Array(items) => { |
| 114 | for item in items { |
| 115 | paths(item, &format!("{path}[]"), out); |
| 116 | } |
| 117 | } |
| 118 | _ => {} |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | #[test] |
| 123 | fn no_route_answers_with_camel_case() { |
| 124 | let document = document(); |
| 125 | let (mut checked, mut converted) = (0, 0); |
| 126 | for (path, methods) in document["paths"].as_object().unwrap() { |
| 127 | for (method, operation) in methods.as_object().unwrap() { |
| 128 | let example = &operation["responses"]["200"]["content"]["application/json"]["example"]; |
| 129 | let tool = operation["x-mcp-tool"].as_str().unwrap_or_default(); |
| 130 | let Some(op) = Op::by_name(tool) else { |
| 131 | // Device sign-in, which is written in `snake_case` by hand. |
| 132 | assert!(wire::camel_case_keys(example).is_empty(), "{method} {path}"); |
| 133 | continue; |
| 134 | }; |
| 135 | let sample = sample(op, example); |
| 136 | converted += wire::camel_case_keys(&sample).len(); |
| 137 | let sent = wire::snake_case(sample); |
| 138 | let leaked = wire::camel_case_keys(&sent); |
| 139 | assert!(leaked.is_empty(), "{method} {path} sends {leaked:?}"); |
| 140 | // The reference shows what is sent: each of its names is one. |
| 141 | let (mut shown, mut real) = (Vec::new(), Vec::new()); |
| 142 | paths(example, "", &mut shown); |
| 143 | paths(&sent, "", &mut real); |
| 144 | for name in shown { |
| 145 | assert!(real.contains(&name), "{method} {path}: the reference shows {name}, which is not sent"); |
| 146 | } |
| 147 | checked += 1; |
| 148 | } |
| 149 | } |
| 150 | assert!(checked >= Op::ALL.len()); |
| 151 | // The samples are in the services' spelling, so there was something to |
| 152 | // convert. |
| 153 | assert!(converted > 100, "{converted}"); |
| 154 | } |
| 155 | |
| 156 | #[test] |
| 157 | fn every_route_has_a_sample() { |
| 158 | let document = document(); |
| 159 | for route in crate::rest::ROUTES { |
| 160 | let path = route |
| 161 | .path |
| 162 | .split('/') |
| 163 | .map(|segment| match segment.strip_prefix(':') { |
| 164 | Some(name) => format!("{{{name}}}"), |
| 165 | None => segment.to_owned(), |
| 166 | }) |
| 167 | .collect::<Vec<_>>() |
| 168 | .join("/"); |
| 169 | let example = &document["paths"][&path][route.method.to_lowercase()]["responses"]["200"] |
| 170 | ["content"]["application/json"]["example"]; |
| 171 | assert!(!example.is_null(), "{} {path}", route.method); |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn errors_and_reports_are_snake_case() { |
| 177 | let failure = g1t_contracts::Failure { |
| 178 | code: g1t_contracts::FailureCode::NotFound, |
| 179 | message: "No such endpoint.".to_owned(), |
| 180 | }; |
| 181 | assert!(wire::camel_case_keys(&wire::snake_case(json!({ "error": failure }))).is_empty()); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn a_job_spec_keeps_github_s_spelling() { |
| 186 | let spec = json!({ |
| 187 | "job": "job_1", |
| 188 | "spec": { "runs-on": "ubuntu-latest", "timeoutMinutes": 5 }, |
| 189 | "workflow": { "env": { "nodeEnv": "x" } }, |
| 190 | "github": { "eventName": "push", "headRef": "" }, |
| 191 | "event": { "pull_request": { "headSha": "x" } }, |
| 192 | "contexts": { "inputs": { "dryRun": true }, "matrix": { "nodeVersion": 20 } }, |
| 193 | "checkout": { "ref": "main" }, |
| 194 | "timeoutMinutes": 30, |
| 195 | "masks": [], |
| 196 | }); |
| 197 | let sent = wire::snake_case_keeping(spec.clone(), crate::JOB_SPEC_AS_GIVEN); |
| 198 | assert_eq!(sent["timeout_minutes"], 30); |
| 199 | assert!(sent.get("timeoutMinutes").is_none()); |
| 200 | for kept in ["spec", "workflow", "github", "event", "contexts", "checkout"] { |
| 201 | assert_eq!(sent[kept], spec[kept], "{kept}"); |
| 202 | } |
| 203 | } |