Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype for jj api #3601

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ test-case = "3.3.1"
textwrap = "0.16.1"
thiserror = "1.0.59"
timeago = { version = "0.4.2", default-features = false }
tokio = { version = "1.37.0" }
tokio = { version = "1.37.0", features = ["rt", "macros"] }
toml_edit = { version = "0.19.15", features = ["serde"] }
tonic = "0.11.0"
tonic-build = "0.11.0"
tonic-web = "0.11.0"
tracing = "0.1.40"
tracing-chrome = "0.7.2"
tracing-subscriber = { version = "0.3.18", default-features = false, features = [
Expand Down
8 changes: 6 additions & 2 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ glob = { workspace = true }
hex = { workspace = true }
ignore = { workspace = true }
itertools = { workspace = true }
jj-api = { workspace = true }
jj-lib-proc-macros = { workspace = true }
maplit = { workspace = true }
once_cell = { workspace = true }
Expand All @@ -66,7 +67,10 @@ smallvec = { workspace = true }
strsim = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, optional = true }
tokio = { workspace = true }
tonic = { workspace = true }
tonic-web = { workspace = true }
prost-types = { workspace = true }
tracing = { workspace = true }
watchman_client = { workspace = true, optional = true }
whoami = { workspace = true }
Expand All @@ -93,5 +97,5 @@ tokio = { workspace = true, features = ["full"] }
[features]
default = []
vendored-openssl = ["git2/vendored-openssl"]
watchman = ["dep:tokio", "dep:watchman_client"]
watchman = ["dep:watchman_client"]
testing = []
9 changes: 9 additions & 0 deletions lib/src/api/from_proto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use crate::op_store::OperationId;
use jj_api::from_proto;
use tonic::Status;

pub(crate) use jj_api::from_proto::*;

pub(crate) fn operation_id(value: &str) -> Result<Option<OperationId>, Status> {
Ok(from_proto::hex(value)?.map(|bytes| OperationId::from_bytes(&bytes)))
}
28 changes: 28 additions & 0 deletions lib/src/api/grpc_servicer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use crate::api::servicer::Servicer;
use jj_api::rpc::{ListWorkspacesRequest, ListWorkspacesResponse};
use jj_api::server::JjService;
use tonic::{Request, Response, Status};

pub struct GrpcServicer {
servicer: Servicer,
}

impl GrpcServicer {
pub fn new(servicer: Servicer) -> Self {
Self { servicer }
}
}

#[tonic::async_trait]
impl JjService for GrpcServicer {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly just to get around the fact this requires async functions, while I intend for the jj CLI to call the sync functions directly.

// TODO: this should be boilerplate. Maybe turn it into macros.
// eg. rpc!(list_workspaces, ListWorkspacesRequest, ListWorkspacesResponse)
async fn list_workspaces(
&self,
request: Request<ListWorkspacesRequest>,
) -> Result<Response<ListWorkspacesResponse>, Status> {
self.servicer
.list_workspaces(request.get_ref())
.map(Response::new)
}
}
6 changes: 6 additions & 0 deletions lib/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
mod from_proto;
mod grpc_servicer;
pub mod servicer;
mod status;

pub mod server;
48 changes: 48 additions & 0 deletions lib/src/api/server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use crate::api::grpc_servicer::GrpcServicer;
use crate::api::servicer::Servicer;
use jj_api::server::JjServiceServer;
use tonic::transport::Server;

pub enum StartupOptions {
Grpc(GrpcOptions),
}

pub struct GrpcOptions {
pub port: u16,
pub web: bool,
}

#[tokio::main(flavor = "current_thread")]
pub async fn start_api(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the fact that the GRPC server is started in the jj_lib crate rather than the jj_cli crate. However, I anticipate that:

  1. The jj daemon will likely need to start this server
  2. My proposal for generalized hook support (FR: Generalized hook support #3577) will require jj_lib to ensure that an API server is running.

Happy to move this to jj_cli if we don't think that's the case.

options: StartupOptions,
servicer: Servicer,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match options {
StartupOptions::Grpc(options) => start_grpc(options, servicer),
}
.await
}

pub async fn start_grpc(
options: GrpcOptions,
servicer: Servicer,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let addr = format!("[::1]:{}", options.port).parse()?;

let server = JjServiceServer::new(GrpcServicer::new(servicer));

let mut builder = Server::builder()
// The gRPC server is inherently async, but we want it to be synchronous.
.concurrency_limit_per_connection(1);
if options.web {
// GrpcWeb is over http1 so we must enable it.
builder
.accept_http1(true)
.add_service(tonic_web::enable(server))
} else {
builder.add_service(server)
}
.serve(addr)
.await?;
Ok(())
}
101 changes: 101 additions & 0 deletions lib/src/api/servicer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use crate::api::from_proto;
use crate::object_id::ObjectId;
use crate::repo::ReadonlyRepo;
use crate::settings::UserSettings;
use config::Config;
use itertools::Itertools;
use jj_api::objects::{Change as ChangeProto, Workspace as WorkspaceProto};
use jj_api::rpc::{ListWorkspacesRequest, ListWorkspacesResponse};
use jj_lib::op_store::OperationId;
use jj_lib::operation::Operation;
use jj_lib::repo::RepoLoader;
use jj_lib::workspace::WorkspaceLoader;

use std::sync::Arc;
use tonic::Status;

/// The servicer handles all requests going to jj-lib. Eventually, ideally, jj-cli
/// will interact with jj-lib purely through this class.
pub struct Servicer {
default_workspace_loader: Option<WorkspaceLoader>,
user_settings: UserSettings,
}

impl Servicer {
pub fn new(default_workspace_loader: Option<WorkspaceLoader>) -> Self {
Self {
default_workspace_loader,
user_settings: UserSettings::from_config(Config::default()),
}
}

fn workspace_loader(
&self,
opts: &Option<jj_api::objects::RepoOptions>,
) -> Result<WorkspaceLoader, Status> {
opts.as_ref()
.map(|opts| from_proto::path(&opts.repo_path))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of work to just get one field, which is why I'm considering a mechanism where we just have:
grpc_servicer.rs would write:

// servicer.rs
fn list_workspaces(TypeSafeListWorkspacesRequest) -> Result<TypeSafeListWorkspacesResponse, Status> {
    ...
}

// grpc_servicer.rs

async fn list_workspaces(
        &self,
        request: Request<ListWorkspacesRequest>,
    ) -> Result<Response<ListWorkspacesResponse>, Status> {
        self.servicer
            .list_workspaces(request.get_ref().try_into()?)
            .to_proto()
            .map(Response::new)
    }

.flatten()
.map(WorkspaceLoader::init)
.transpose()?
.or(self.default_workspace_loader.clone())
.ok_or_else(|| {
Status::invalid_argument(
"No default workspace loader, and no repository.repo_path provided",
)
})
}

fn repo(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that we may be able to take advantage of caching to improve performance of functions like this. Seems like work for later though

&self,
opts: &Option<jj_api::objects::RepoOptions>,
) -> Result<Arc<ReadonlyRepo>, Status> {
let workspace_loader = self.workspace_loader(opts)?;

let at_operation: Option<OperationId> = opts
.as_ref()
.map(|opts| from_proto::operation_id(&opts.at_operation))
.transpose()?
.flatten();

let repo_loader = RepoLoader::init(
&self.user_settings,
&workspace_loader.repo_path(),
&Default::default(),
)?;

Ok(match at_operation {
None => repo_loader.load_at_head(&self.user_settings),
Some(at_operation) => {
let op = repo_loader.op_store().read_operation(&at_operation)?;
repo_loader.load_at(&Operation::new(
repo_loader.op_store().clone(),
at_operation,
op,
))
}
}?)
}

pub fn list_workspaces(
&self,
request: &ListWorkspacesRequest,
) -> Result<ListWorkspacesResponse, Status> {
let repo = self.repo(&request.repo)?;
Ok(ListWorkspacesResponse {
workspace: repo
.view()
.wc_commit_ids()
.iter()
.sorted()
.map(|(workspace_id, commit_id)| WorkspaceProto {
workspace_id: workspace_id.as_str().to_string(),
change: Some(ChangeProto {
commit_id: commit_id.hex(),
..Default::default()
}),
})
.collect::<Vec<WorkspaceProto>>(),
})
}
}
40 changes: 40 additions & 0 deletions lib/src/api/status.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use crate::repo::{RepoLoaderError, StoreLoadError};
use jj_lib::op_store::OpStoreError;
use jj_lib::workspace::WorkspaceLoadError;
use tonic::Status;

impl From<StoreLoadError> for Status {
fn from(value: StoreLoadError) -> Status {
Status::internal(value.to_string())
}
}

impl From<RepoLoaderError> for Status {
fn from(value: RepoLoaderError) -> Status {
(match value {
RepoLoaderError::OpHeadResolution { .. } => Status::not_found,
_ => Status::internal,
})(value.to_string())
}
}

impl From<OpStoreError> for Status {
fn from(value: OpStoreError) -> Status {
(match value {
OpStoreError::ObjectNotFound { .. } => Status::not_found,
_ => Status::internal,
})(value.to_string())
}
}

impl From<WorkspaceLoadError> for Status {
fn from(value: WorkspaceLoadError) -> Status {
(match value {
WorkspaceLoadError::RepoDoesNotExist(_)
| WorkspaceLoadError::NoWorkspaceHere(_)
| WorkspaceLoadError::NonUnicodePath
| WorkspaceLoadError::Path(_) => Status::invalid_argument,
_ => Status::internal,
})(value.to_string())
}
}
1 change: 1 addition & 0 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ extern crate self as jj_lib;
#[macro_use]
pub mod content_hash;

pub mod api;
pub mod backend;
pub mod commit;
pub mod commit_builder;
Expand Down