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