497
crates/forage-server/src/forest_client.rs
Normal file
497
crates/forage-server/src/forest_client.rs
Normal file
@@ -0,0 +1,497 @@
|
||||
use forage_core::auth::{
|
||||
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
|
||||
};
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError,
|
||||
};
|
||||
use forage_grpc::organisation_service_client::OrganisationServiceClient;
|
||||
use forage_grpc::release_service_client::ReleaseServiceClient;
|
||||
use forage_grpc::users_service_client::UsersServiceClient;
|
||||
use tonic::metadata::MetadataValue;
|
||||
use tonic::transport::Channel;
|
||||
use tonic::Request;
|
||||
|
||||
fn bearer_request<T>(access_token: &str, msg: T) -> Result<Request<T>, String> {
|
||||
let mut req = Request::new(msg);
|
||||
let bearer: MetadataValue<_> = format!("Bearer {access_token}")
|
||||
.parse()
|
||||
.map_err(|_| "invalid token format".to_string())?;
|
||||
req.metadata_mut().insert("authorization", bearer);
|
||||
Ok(req)
|
||||
}
|
||||
|
||||
/// Real gRPC client to forest-server's UsersService.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcForestClient {
|
||||
channel: Channel,
|
||||
}
|
||||
|
||||
impl GrpcForestClient {
|
||||
/// Create a client that connects lazily (for when server may not be available at startup).
|
||||
pub fn connect_lazy(endpoint: &str) -> anyhow::Result<Self> {
|
||||
let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy();
|
||||
Ok(Self { channel })
|
||||
}
|
||||
|
||||
fn client(&self) -> UsersServiceClient<Channel> {
|
||||
UsersServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
fn org_client(&self) -> OrganisationServiceClient<Channel> {
|
||||
OrganisationServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
fn release_client(&self) -> ReleaseServiceClient<Channel> {
|
||||
ReleaseServiceClient::new(self.channel.clone())
|
||||
}
|
||||
|
||||
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
|
||||
bearer_request(access_token, msg).map_err(AuthError::Other)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_status(status: tonic::Status) -> AuthError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated => AuthError::InvalidCredentials,
|
||||
tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()),
|
||||
tonic::Code::PermissionDenied => AuthError::NotAuthenticated,
|
||||
tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()),
|
||||
_ => AuthError::Other(status.message().into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_user(u: forage_grpc::User) -> User {
|
||||
User {
|
||||
user_id: u.user_id,
|
||||
username: u.username,
|
||||
emails: u
|
||||
.emails
|
||||
.into_iter()
|
||||
.map(|e| UserEmail {
|
||||
email: e.email,
|
||||
verified: e.verified,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken {
|
||||
PersonalAccessToken {
|
||||
token_id: t.token_id,
|
||||
name: t.name,
|
||||
scopes: t.scopes,
|
||||
created_at: t.created_at.map(|ts| ts.to_string()),
|
||||
last_used: t.last_used.map(|ts| ts.to_string()),
|
||||
expires_at: t.expires_at.map(|ts| ts.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ForestAuth for GrpcForestClient {
|
||||
async fn register(
|
||||
&self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError> {
|
||||
let resp = self
|
||||
.client()
|
||||
.register(forage_grpc::RegisterRequest {
|
||||
username: username.into(),
|
||||
email: email.into(),
|
||||
password: password.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
|
||||
Ok(AuthTokens {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_in_seconds: tokens.expires_in_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
async fn login(&self, identifier: &str, password: &str) -> Result<AuthTokens, AuthError> {
|
||||
let login_identifier = if identifier.contains('@') {
|
||||
forage_grpc::login_request::Identifier::Email(identifier.into())
|
||||
} else {
|
||||
forage_grpc::login_request::Identifier::Username(identifier.into())
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.login(forage_grpc::LoginRequest {
|
||||
identifier: Some(login_identifier),
|
||||
password: password.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
|
||||
Ok(AuthTokens {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_in_seconds: tokens.expires_in_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthTokens, AuthError> {
|
||||
let resp = self
|
||||
.client()
|
||||
.refresh_token(forage_grpc::RefreshTokenRequest {
|
||||
refresh_token: refresh_token.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let tokens = resp
|
||||
.tokens
|
||||
.ok_or(AuthError::Other("no tokens in response".into()))?;
|
||||
Ok(AuthTokens {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_in_seconds: tokens.expires_in_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> {
|
||||
self.client()
|
||||
.logout(forage_grpc::LogoutRequest {
|
||||
refresh_token: refresh_token.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_status)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::TokenInfoRequest {},
|
||||
)?;
|
||||
|
||||
let info = self
|
||||
.client()
|
||||
.token_info(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetUserRequest {
|
||||
identifier: Some(forage_grpc::get_user_request::Identifier::UserId(
|
||||
info.user_id,
|
||||
)),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.get_user(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
|
||||
Ok(convert_user(user))
|
||||
}
|
||||
|
||||
async fn list_tokens(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<PersonalAccessToken>, AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::ListPersonalAccessTokensRequest {
|
||||
user_id: user_id.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.list_personal_access_tokens(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.tokens.into_iter().map(convert_token).collect())
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<CreatedToken, AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::CreatePersonalAccessTokenRequest {
|
||||
user_id: user_id.into(),
|
||||
name: name.into(),
|
||||
scopes: vec![],
|
||||
expires_in_seconds: 0,
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.client()
|
||||
.create_personal_access_token(req)
|
||||
.await
|
||||
.map_err(map_status)?
|
||||
.into_inner();
|
||||
|
||||
let token = resp
|
||||
.token
|
||||
.ok_or(AuthError::Other("no token in response".into()))?;
|
||||
Ok(CreatedToken {
|
||||
token: convert_token(token),
|
||||
raw_token: resp.raw_token,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_token(
|
||||
&self,
|
||||
access_token: &str,
|
||||
token_id: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
let req = Self::authed_request(
|
||||
access_token,
|
||||
forage_grpc::DeletePersonalAccessTokenRequest {
|
||||
token_id: token_id.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
self.client()
|
||||
.delete_personal_access_token(req)
|
||||
.await
|
||||
.map_err(map_status)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_organisations(
|
||||
organisations: Vec<forage_grpc::Organisation>,
|
||||
roles: Vec<String>,
|
||||
) -> Vec<Organisation> {
|
||||
organisations
|
||||
.into_iter()
|
||||
.zip(roles)
|
||||
.map(|(org, role)| Organisation {
|
||||
organisation_id: org.organisation_id,
|
||||
name: org.name,
|
||||
role,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
|
||||
let ctx = a.context.unwrap_or_default();
|
||||
Artifact {
|
||||
artifact_id: a.artifact_id,
|
||||
slug: a.slug,
|
||||
context: ArtifactContext {
|
||||
title: ctx.title,
|
||||
description: if ctx.description.as_deref() == Some("") {
|
||||
None
|
||||
} else {
|
||||
ctx.description
|
||||
},
|
||||
},
|
||||
created_at: a.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_platform_status(status: tonic::Status) -> PlatformError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
|
||||
PlatformError::NotAuthenticated
|
||||
}
|
||||
tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
|
||||
tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
|
||||
_ => PlatformError::Other(status.message().into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, PlatformError> {
|
||||
bearer_request(access_token, msg).map_err(PlatformError::Other)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ForestPlatform for GrpcForestClient {
|
||||
async fn list_my_organisations(
|
||||
&self,
|
||||
access_token: &str,
|
||||
) -> Result<Vec<Organisation>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::ListMyOrganisationsRequest { role: String::new() },
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.org_client()
|
||||
.list_my_organisations(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(convert_organisations(resp.organisations, resp.roles))
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<String>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetProjectsRequest {
|
||||
query: Some(forage_grpc::get_projects_request::Query::Organisation(
|
||||
forage_grpc::OrganisationRef {
|
||||
organisation: organisation.into(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_projects(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.projects)
|
||||
}
|
||||
|
||||
async fn list_artifacts(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
) -> Result<Vec<Artifact>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::GetArtifactsByProjectRequest {
|
||||
project: Some(forage_grpc::Project {
|
||||
organisation: organisation.into(),
|
||||
project: project.into(),
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.release_client()
|
||||
.get_artifacts_by_project(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.artifact.into_iter().map(convert_artifact).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_org(id: &str, name: &str) -> forage_grpc::Organisation {
|
||||
forage_grpc::Organisation {
|
||||
organisation_id: id.into(),
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_artifact(slug: &str, ctx: Option<forage_grpc::ArtifactContext>) -> forage_grpc::Artifact {
|
||||
forage_grpc::Artifact {
|
||||
artifact_id: "a1".into(),
|
||||
slug: slug.into(),
|
||||
context: ctx,
|
||||
created_at: "2026-01-01".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_organisations_pairs_orgs_with_roles() {
|
||||
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
|
||||
let roles = vec!["owner".into(), "member".into()];
|
||||
|
||||
let result = convert_organisations(orgs, roles);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].name, "alpha");
|
||||
assert_eq!(result[0].role, "owner");
|
||||
assert_eq!(result[1].name, "beta");
|
||||
assert_eq!(result[1].role, "member");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_organisations_truncates_when_roles_shorter() {
|
||||
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
|
||||
let roles = vec!["owner".into()]; // only 1 role for 2 orgs
|
||||
|
||||
let result = convert_organisations(orgs, roles);
|
||||
// zip truncates to shorter iterator
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_organisations_empty() {
|
||||
let result = convert_organisations(vec![], vec![]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_with_full_context() {
|
||||
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
||||
title: "My API".into(),
|
||||
description: Some("A cool API".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert_eq!(result.slug, "my-api");
|
||||
assert_eq!(result.context.title, "My API");
|
||||
assert_eq!(result.context.description.as_deref(), Some("A cool API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_empty_description_becomes_none() {
|
||||
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
||||
title: "My API".into(),
|
||||
description: Some(String::new()),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert!(result.context.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_missing_context_uses_defaults() {
|
||||
let a = make_artifact("my-api", None);
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert_eq!(result.context.title, "");
|
||||
assert!(result.context.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_artifact_none_description_stays_none() {
|
||||
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
|
||||
title: "My API".into(),
|
||||
description: None,
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let result = convert_artifact(a);
|
||||
assert!(result.context.description.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user