pr_01m47d15m3e54sn21z27rpy5n9/apps/api/src/lib.rs
| 1 | //! The public API: REST at api.g1t.sh and the MCP server at mcp.g1t.sh. |
| 2 | //! |
| 3 | //! One Worker, two hostnames. Both are thin adapters over the same |
| 4 | //! operations (see [`operations::Op`]), which call the services that own |
| 5 | //! the data. This Worker holds none. |
| 6 | |
| 7 | mod mcp; |
| 8 | mod oauth; |
| 9 | mod openapi; |
| 10 | mod operations; |
| 11 | mod rest; |
| 12 | |
| 13 | use g1t_contracts::identity::{ |
| 14 | DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs, |
| 15 | }; |
| 16 | use g1t_contracts::work::{CheckRun, ReportChecksArgs}; |
| 17 | use g1t_contracts::{Failure, FailureCode, Outcome, Viewer}; |
| 18 | use serde_json::{Value, json}; |
| 19 | use worker::{Context, Env, Method, Request, Response, Result, event}; |
| 20 | |
| 21 | use operations::Services; |
| 22 | |
| 23 | const API: &str = "https://api.g1t.sh"; |
| 24 | |
| 25 | fn method_name(method: Method) -> &'static str { |
| 26 | match method { |
| 27 | Method::Get => "GET", |
| 28 | Method::Post => "POST", |
| 29 | Method::Patch => "PATCH", |
| 30 | Method::Put => "PUT", |
| 31 | Method::Delete => "DELETE", |
| 32 | Method::Options => "OPTIONS", |
| 33 | Method::Head => "HEAD", |
| 34 | _ => "OTHER", |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | /// An error in the shape every endpoint uses. |
| 39 | fn failure(failure: &Failure) -> Result<Response> { |
| 40 | Ok(Response::from_json(&json!({ "error": failure }))?.with_status(failure.code.http_status())) |
| 41 | } |
| 42 | |
| 43 | fn fail(code: FailureCode, message: &str) -> Result<Response> { |
| 44 | failure(&Failure { |
| 45 | code, |
| 46 | message: message.to_owned(), |
| 47 | }) |
| 48 | } |
| 49 | |
| 50 | /// A request body as JSON. An empty or malformed body is no input. |
| 51 | async fn json_body(request: &mut Request) -> Value { |
| 52 | request.json().await.unwrap_or(Value::Null) |
| 53 | } |
| 54 | |
| 55 | /// Who a request's `Authorization: Bearer g1t_…` names. A missing token is |
| 56 | /// an anonymous viewer; a wrong one is refused, so that a typo does not |
| 57 | /// silently look signed out. |
| 58 | async fn authenticate( |
| 59 | request: &Request, |
| 60 | services: &Services, |
| 61 | ) -> Result<std::result::Result<Viewer, Response>> { |
| 62 | let header = request.headers().get("authorization")?.unwrap_or_default(); |
| 63 | let token = match header.split_once(' ') { |
| 64 | Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() => { |
| 65 | token.trim() |
| 66 | } |
| 67 | _ => return Ok(Ok(None)), |
| 68 | }; |
| 69 | let viewer: Viewer = g1t_kit::call( |
| 70 | &services.identity, |
| 71 | "user_for_access_token", |
| 72 | &TokenArgs { |
| 73 | token: token.to_owned(), |
| 74 | }, |
| 75 | ) |
| 76 | .await?; |
| 77 | if viewer.is_some() { |
| 78 | return Ok(Ok(viewer)); |
| 79 | } |
| 80 | let mut response = fail(FailureCode::Unauthenticated, "Invalid access token.")?; |
| 81 | // Tells an MCP client where to sign in again. |
| 82 | response.headers_mut().set( |
| 83 | "www-authenticate", |
| 84 | &format!("{}, error=\"invalid_token\"", oauth::MCP_CHALLENGE), |
| 85 | )?; |
| 86 | Ok(Err(response)) |
| 87 | } |
| 88 | |
| 89 | /// Where everything is, for someone or something exploring the API. |
| 90 | fn index() -> Value { |
| 91 | let repo = format!("{API}/v1/repos/{{owner}}/{{name}}"); |
| 92 | json!({ |
| 93 | "documentation_url": "https://docs.g1t.sh/api/reference/", |
| 94 | "openapi_url": format!("{API}/openapi.json"), |
| 95 | "mcp_url": "https://mcp.g1t.sh", |
| 96 | "current_user_url": format!("{API}/v1/user"), |
| 97 | "workspaces_url": format!("{API}/v1/workspaces"), |
| 98 | "repositories_url": format!("{API}/v1/repos{{?q}}"), |
| 99 | "repository_url": repo, |
| 100 | "repository_events_url": format!("{repo}/events{{?before}}"), |
| 101 | "labels_url": format!("{repo}/labels"), |
| 102 | "issues_url": format!("{repo}/issues{{?state,label}}"), |
| 103 | "issue_url": format!("{repo}/issues/{{number}}"), |
| 104 | "issue_comments_url": format!("{repo}/issues/{{number}}/comments"), |
| 105 | "pulls_url": format!("{repo}/pulls{{?state}}"), |
| 106 | "pull_url": format!("{repo}/pulls/{{number}}"), |
| 107 | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), |
| 108 | "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"), |
| 109 | "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"), |
| 110 | "device_code_url": format!("{API}/v1/device/code"), |
| 111 | "device_token_url": format!("{API}/v1/device/token"), |
| 112 | "oauth_metadata_url": format!("{API}/.well-known/oauth-authorization-server"), |
| 113 | "git_url": "https://g1t.sh/{owner}/{name}.git", |
| 114 | }) |
| 115 | } |
| 116 | |
| 117 | // Signing in from a tool. Accounts are created, and passwords typed, only |
| 118 | // in a browser; a tool gets its token by having a person approve a code. |
| 119 | |
| 120 | async fn device_code(request: &mut Request, services: &Services) -> Result<Response> { |
| 121 | let body = json_body(request).await; |
| 122 | let started: DeviceStart = g1t_kit::call( |
| 123 | &services.identity, |
| 124 | "device_start", |
| 125 | &DeviceStartArgs { |
| 126 | client_name: body["client_name"].as_str().unwrap_or_default().to_owned(), |
| 127 | }, |
| 128 | ) |
| 129 | .await?; |
| 130 | Response::from_json(&json!({ |
| 131 | "device_code": started.device_code, |
| 132 | "user_code": started.user_code, |
| 133 | "verification_uri": "https://g1t.sh/device", |
| 134 | "verification_uri_complete": format!("https://g1t.sh/device?code={}", started.user_code), |
| 135 | "expires_in": started.expires_in, |
| 136 | "interval": started.interval, |
| 137 | })) |
| 138 | } |
| 139 | |
| 140 | async fn device_token(request: &mut Request, services: &Services) -> Result<Response> { |
| 141 | let body = json_body(request).await; |
| 142 | let claim: DeviceClaim = g1t_kit::call( |
| 143 | &services.identity, |
| 144 | "device_claim", |
| 145 | &DeviceClaimArgs { |
| 146 | device_code: body["device_code"].as_str().unwrap_or_default().to_owned(), |
| 147 | }, |
| 148 | ) |
| 149 | .await?; |
| 150 | Response::from_json(&match claim { |
| 151 | DeviceClaim::Approved { token, user } => json!({ |
| 152 | "status": "approved", |
| 153 | "token": token, |
| 154 | "username": user.username, |
| 155 | "verified": user.verified, |
| 156 | }), |
| 157 | DeviceClaim::Pending => json!({ "status": "pending" }), |
| 158 | DeviceClaim::Denied => json!({ "status": "denied" }), |
| 159 | DeviceClaim::Expired => json!({ "status": "expired" }), |
| 160 | }) |
| 161 | } |
| 162 | |
| 163 | /// A sandbox reporting on its run of a pull request's acceptance checks. |
| 164 | /// The run's own token, in the body, is the credential: it was given to |
| 165 | /// that sandbox and to nothing else. |
| 166 | async fn report_checks( |
| 167 | request: &mut Request, |
| 168 | services: &Services, |
| 169 | run_id: &str, |
| 170 | ) -> Result<Response> { |
| 171 | let body = json_body(request).await; |
| 172 | let reported: Outcome<CheckRun> = g1t_kit::call( |
| 173 | &services.work, |
| 174 | "report_checks", |
| 175 | &ReportChecksArgs { |
| 176 | run_id: run_id.to_owned(), |
| 177 | token: body["token"].as_str().unwrap_or_default().to_owned(), |
| 178 | results: serde_json::from_value(body["results"].clone()).unwrap_or_default(), |
| 179 | error: body["error"].as_str().map(str::to_owned), |
| 180 | skip: false, |
| 181 | }, |
| 182 | ) |
| 183 | .await?; |
| 184 | match reported { |
| 185 | Outcome::Ok(run) => Response::from_json(&json!({ "status": run.status })), |
| 186 | Outcome::Fail(refused) => failure(&refused), |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | async fn respond(mut request: Request, env: &Env) -> Result<Response> { |
| 191 | let method = method_name(request.method()); |
| 192 | if method == "OPTIONS" { |
| 193 | return Ok(Response::empty()?.with_status(204)); |
| 194 | } |
| 195 | let url = request.url()?; |
| 196 | let path = url.path().to_owned(); |
| 197 | let on_mcp = url.host_str().is_some_and(|host| host.starts_with("mcp.")); |
| 198 | let services = Services::new(env)?; |
| 199 | |
| 200 | let viewer = match authenticate(&request, &services).await? { |
| 201 | Ok(viewer) => viewer, |
| 202 | Err(refused) => return Ok(refused), |
| 203 | }; |
| 204 | if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? { |
| 205 | return Ok(response); |
| 206 | } |
| 207 | if on_mcp { |
| 208 | return mcp::handle(request, &services, &viewer).await; |
| 209 | } |
| 210 | |
| 211 | match (method, path.trim_end_matches('/')) { |
| 212 | ("GET", "" | "/v1") => return Response::from_json(&index()), |
| 213 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), |
| 214 | ("POST", "/v1/device/code") => return device_code(&mut request, &services).await, |
| 215 | ("POST", "/v1/device/token") => return device_token(&mut request, &services).await, |
| 216 | ("POST", path) if path.starts_with("/v1/checks/") => { |
| 217 | let run_id = path.trim_start_matches("/v1/checks/").to_owned(); |
| 218 | return report_checks(&mut request, &services, &run_id).await; |
| 219 | } |
| 220 | _ => {} |
| 221 | } |
| 222 | |
| 223 | let query: Vec<(String, String)> = url |
| 224 | .query_pairs() |
| 225 | .map(|(name, value)| (name.into_owned(), value.into_owned())) |
| 226 | .collect(); |
| 227 | let body = if method == "GET" { |
| 228 | Value::Null |
| 229 | } else { |
| 230 | json_body(&mut request).await |
| 231 | }; |
| 232 | let Some((route, input)) = rest::resolve(method, &path, &query, body) else { |
| 233 | return fail(FailureCode::NotFound, "No such endpoint."); |
| 234 | }; |
| 235 | match route.op.run(&services, &viewer, &input).await? { |
| 236 | Outcome::Ok(value) => Response::from_json(&value), |
| 237 | Outcome::Fail(refused) => failure(&refused), |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // The API is called from browsers too: the reference's explorer, and apps |
| 242 | // built on g1t. It carries no cookies, so any origin may call it. |
| 243 | #[event(fetch)] |
| 244 | async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> { |
| 245 | let mut response = respond(request, &env).await?; |
| 246 | let headers = response.headers_mut(); |
| 247 | headers.set("access-control-allow-origin", "*")?; |
| 248 | headers.set( |
| 249 | "access-control-allow-headers", |
| 250 | "authorization, content-type", |
| 251 | )?; |
| 252 | headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?; |
| 253 | headers.set("access-control-expose-headers", "www-authenticate")?; |
| 254 | Ok(response) |
| 255 | } |