feat: migrate auth and workspace catalog to ServerApi

This commit is contained in:
2026-09-23 02:40:51 +09:00
parent 10b3740696
commit c8766e9fb9
6 changed files with 2638 additions and 379 deletions
+55 -46
View File
@@ -1,9 +1,11 @@
use crate::BackendOrigin;
use serde::Deserialize;
use std::fmt;
use std::time::Duration;
use server_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest};
use server_api::{
DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest, RepositoryApiError,
ServerApiClient,
};
pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -19,21 +21,12 @@ impl BackendAuthTarget {
.unwrap_or(base_url);
Self { base_url }
}
fn api_url(&self, path: &str) -> String {
format!(
"{}{}",
self.base_url.trim_end_matches('/'),
path.strip_prefix('/')
.map(|path| format!("/{path}"))
.unwrap_or_else(|| path.to_string())
)
}
}
#[derive(Debug)]
pub enum BackendAuthClientError {
Http(reqwest::Error),
InvalidTarget(String),
ServerApi(server_api::client_support::ClientError<RepositoryApiError>),
BackendStatus { status: u16, body: String },
MissingAccessToken,
}
@@ -41,7 +34,8 @@ pub enum BackendAuthClientError {
impl fmt::Display for BackendAuthClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http(error) => write!(f, "Backend auth request failed: {error}"),
Self::InvalidTarget(message) => f.write_str(message),
Self::ServerApi(error) => write!(f, "{error}"),
Self::BackendStatus { status, body } => {
write!(f, "Backend auth returned HTTP {status}: {body}")
}
@@ -54,40 +48,38 @@ impl fmt::Display for BackendAuthClientError {
impl std::error::Error for BackendAuthClientError {}
impl From<reqwest::Error> for BackendAuthClientError {
fn from(value: reqwest::Error) -> Self {
Self::Http(value)
}
const AUTH_RESPONSE_LIMIT: usize = 2 * 1024 * 1024;
fn auth_client(target: &BackendAuthTarget) -> Result<ServerApiClient, BackendAuthClientError> {
ServerApiClient::builder(&target.base_url)
.map_err(|error| BackendAuthClientError::InvalidTarget(error.to_string()))?
.response_body_limit(AUTH_RESPONSE_LIMIT)
.build()
.map_err(|error| BackendAuthClientError::InvalidTarget(error.to_string()))
}
pub async fn start_device_login(
target: &BackendAuthTarget,
client_name: Option<&str>,
) -> Result<DeviceLoginStartResponse, BackendAuthClientError> {
let client = reqwest::Client::new();
let response = client
.post(target.api_url("/api/auth/device-login/start"))
.json(&DeviceLoginStartRequest {
auth_client(target)?
.auth_device_login_start(DeviceLoginStartRequest {
client_name: client_name.map(ToOwned::to_owned),
})
.send()
.await?;
parse_json_response(response).await
.await
.map_err(BackendAuthClientError::ServerApi)
}
pub async fn poll_device_login(
target: &BackendAuthTarget,
device_code: &str,
) -> Result<DeviceLoginPollResponse, BackendAuthClientError> {
let client = reqwest::Client::new();
let response = client
.post(target.api_url("/api/auth/device-login/poll"))
.json(&DeviceLoginPollRequest {
auth_client(target)?
.auth_device_login_poll(DeviceLoginPollRequest {
device_code: device_code.to_string(),
})
.send()
.await?;
parse_json_response(response).await
.await
.map_err(BackendAuthClientError::ServerApi)
}
fn device_login_poll_result(
@@ -136,24 +128,13 @@ pub async fn wait_for_device_login(
}
}
async fn parse_json_response<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, BackendAuthClientError> {
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(BackendAuthClientError::BackendStatus {
status: status.as_u16(),
body,
});
}
Ok(response.json::<T>().await?)
}
#[cfg(test)]
mod tests {
use super::*;
use server_api::DeviceAccessTokenType;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse {
DeviceLoginPollResponse {
@@ -163,6 +144,34 @@ mod tests {
}
}
#[tokio::test]
async fn device_login_start_uses_generated_server_api_client() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(request.starts_with("POST /api/auth/device-login/start HTTP/1.1"));
assert!(request.contains(r#"{"client_name":"yoi-cli"}"#));
let body = r#"{"device_code":"device-secret","user_code":"ABCD-EFGH","verification_uri":"https://yoi.example/login/device","verification_uri_complete":"https://yoi.example/login/device?user_code=ABCD-EFGH","expires_in":600,"interval":5}"#;
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let response = start_device_login(&BackendAuthTarget::new(base_url), Some("yoi-cli"))
.await
.unwrap();
assert_eq!(response.user_code, "ABCD-EFGH");
handle.join().unwrap();
}
#[test]
fn device_login_start_response_enforces_shared_expiry_bounds() {
let valid = serde_json::json!({
+49 -53
View File
@@ -1,8 +1,7 @@
use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use server_api::{
InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse,
WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary,
InitialRepositoryIntent, RepositorySummary, WorkspaceCreateRequest, WorkspaceCreateResponse,
WorkspaceListQuery, WorkspaceSummary,
};
use std::fmt;
@@ -31,7 +30,7 @@ pub enum BackendWorkspaceClientError {
InvalidTarget(String),
Api(BackendApiClientError),
Http(reqwest::Error),
RepositoryApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
ServerApi(server_api::client_support::ClientError<server_api::RepositoryApiError>),
}
impl fmt::Display for BackendWorkspaceClientError {
@@ -40,7 +39,7 @@ impl fmt::Display for BackendWorkspaceClientError {
Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"),
Self::RepositoryApi(error) => write!(f, "{error}"),
Self::ServerApi(error) => write!(f, "{error}"),
}
}
}
@@ -65,11 +64,11 @@ impl From<server_api::client_support::ClientError<server_api::RepositoryApiError
fn from(
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> Self {
Self::RepositoryApi(error)
Self::ServerApi(error)
}
}
fn repository_client_error(
fn server_client_error(
backend: &BackendApiClient,
error: server_api::client_support::ClientError<server_api::RepositoryApiError>,
) -> BackendWorkspaceClientError {
@@ -88,16 +87,16 @@ fn repository_client_error(
origin: backend.origin().clone(),
})
}
_ => BackendWorkspaceClientError::RepositoryApi(error),
_ => BackendWorkspaceClientError::ServerApi(error),
}
}
#[derive(Clone, Debug)]
struct RepositoryBearerAuthorizer {
struct ServerBearerAuthorizer {
authorization: String,
}
impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer {
impl server_api::client_support::RequestAuthorizer for ServerBearerAuthorizer {
fn authorize(
&self,
_request: server_api::client_support::AuthorizerRequest<'_>,
@@ -113,20 +112,31 @@ impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorize
}
}
const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
const SERVER_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
fn server_api_client(
backend: &BackendApiClient,
) -> Result<server_api::ServerApiClient<ServerBearerAuthorizer>, BackendWorkspaceClientError> {
server_api::ServerApiClient::builder(backend.origin().as_str())
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?
.client(backend.asynchronous_client())
.authorizer(ServerBearerAuthorizer {
authorization: backend.authorization_header_value(),
})
.response_body_limit(SERVER_RESPONSE_LIMIT)
.build()
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))
}
pub fn list_backend_workspaces_blocking(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.blocking_request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
runtime.block_on(list_backend_workspaces_with_client(&client))
}
pub fn list_backend_workspace_repositories_blocking(
@@ -158,19 +168,10 @@ async fn list_backend_workspace_repositories_with_client(
"Workspace id returned by Backend is invalid".to_string(),
));
}
let client = server_api::ServerApiClient::builder(backend.origin().as_str())
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?
.client(backend.asynchronous_client())
.authorizer(RepositoryBearerAuthorizer {
authorization: backend.authorization_header_value(),
})
.response_body_limit(REPOSITORY_RESPONSE_LIMIT)
.build()
.map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?;
let response = client
let response = server_api_client(backend)?
.repository_list(workspace_id.to_owned())
.await
.map_err(|error| repository_client_error(backend, error))?;
.map_err(|error| server_client_error(backend, error))?;
if response.workspace_id != workspace_id {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Repository catalog response does not match the requested Workspace".to_string(),
@@ -189,15 +190,13 @@ pub async fn list_backend_workspaces(
async fn list_backend_workspaces_with_client(
client: &BackendApiClient,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let response = client
.request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
let response = server_api_client(client)?
.workspace_catalog_list(WorkspaceListQuery {
limit: Some(DEFAULT_WORKSPACE_LIMIT as u32),
})
.await
.map_err(|error| server_client_error(client, error))?;
Ok(response.0)
}
pub async fn create_backend_workspace(
@@ -205,13 +204,10 @@ pub async fn create_backend_workspace(
request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.request(Method::POST, "/api/workspaces")?
.json(request)
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
server_api_client(&client)?
.workspace_catalog_create(request.clone())
.await
.map_err(|error| server_client_error(&client, error))
}
#[cfg(test)]
@@ -292,12 +288,12 @@ mod tests {
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
REPOSITORY_RESPONSE_LIMIT + 1,
SERVER_RESPONSE_LIMIT + 1,
)
.unwrap();
stream.flush().unwrap();
let chunk = [b' '; 8192];
let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1;
let mut remaining = SERVER_RESPONSE_LIMIT + 1;
while remaining > 0 {
let length = remaining.min(chunk.len());
if stream.write_all(&chunk[..length]).is_err() {
@@ -315,10 +311,10 @@ mod tests {
assert!(
matches!(
&error,
BackendWorkspaceClientError::RepositoryApi(
BackendWorkspaceClientError::ServerApi(
server_api::client_support::ClientError::Failure(
server_api::client_support::ClientFailure::ResponseTooLarge {
limit: REPOSITORY_RESPONSE_LIMIT
limit: SERVER_RESPONSE_LIMIT
}
)
)
@@ -329,11 +325,11 @@ mod tests {
}
#[test]
fn repository_client_preserves_authentication_error_taxonomy() {
fn server_client_preserves_authentication_error_taxonomy() {
let client =
BackendApiClient::from_access_token_for_test("https://backend.example.test", "token")
.unwrap();
let unauthorized = repository_client_error(
let unauthorized = server_client_error(
&client,
server_api::client_support::ClientError::public(
reqwest::StatusCode::UNAUTHORIZED,
@@ -349,7 +345,7 @@ mod tests {
unauthorized,
BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. })
));
let forbidden = repository_client_error(
let forbidden = server_client_error(
&client,
server_api::client_support::ClientError::public(
reqwest::StatusCode::FORBIDDEN,