pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/lib.rs

222 lines8,177 bytesCodeBlame

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 root1//! 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
7mod mcp;
8mod oauth;
9mod openapi;
10mod operations;
11mod rest;
12
13use g1t_contracts::identity::{
14 DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs,
15};
16use g1t_contracts::{Failure, FailureCode, Outcome, Viewer};
17use serde_json::{Value, json};
18use worker::{Context, Env, Method, Request, Response, Result, event};
19
20use operations::Services;
21
22const API: &str = "https://api.g1t.sh";
23
24fn 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.
38fn failure(failure: &Failure) -> Result<Response> {
39 Ok(Response::from_json(&json!({ "error": failure }))?.with_status(failure.code.http_status()))
40}
41
42fn 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.
50async 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.
57async 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.
89fn 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
118async 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
138async 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
161async 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)]
211async 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}