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

Initial implementation of HTTP registry client #803

Merged
merged 1 commit into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ deno_task_shell = "0.13"
derive_builder = "0.12"
directories = "5"
dunce = "1"
fs4 = "0.7"
fs4 = { version = "0.7", features = ["tokio"] }
fs_extra = "1.3.0"
futures = { version = "0.3", default-features = false, features = ["std", "async-await"] }
gix = "0.54"
Expand Down
163 changes: 163 additions & 0 deletions scarb/src/core/registry/client/http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{Context, Result};
use async_trait::async_trait;
use fs4::tokio::AsyncFileExt;
use futures::StreamExt;
use reqwest::StatusCode;
use tokio::fs::OpenOptions;
use tokio::io;
use tokio::io::BufWriter;
use tokio::sync::OnceCell;
use tracing::{debug, trace};

use crate::core::registry::client::RegistryClient;
use crate::core::registry::index::{IndexConfig, IndexRecords};
use crate::core::{Config, Package, PackageId, PackageName, SourceId};
use crate::flock::{FileLockGuard, Filesystem};

// TODO(mkaput): Honour ETag and Last-Modified headers.
// TODO(mkaput): Progressbar.
// TODO(mkaput): Request timeout.

/// Remote registry served by the HTTP-based registry API.
pub struct HttpRegistryClient<'c> {
source_id: SourceId,
config: &'c Config,
cached_index_config: OnceCell<IndexConfig>,
dl_fs: Filesystem<'c>,
}

impl<'c> HttpRegistryClient<'c> {
pub fn new(source_id: SourceId, config: &'c Config) -> Result<Self> {
let dl_fs = config
.dirs()
.registry_dir()
.into_child("dl")
.into_child(source_id.ident());

Ok(Self {
source_id,
config,
cached_index_config: Default::default(),
dl_fs,
})
}

async fn index_config(&self) -> Result<&IndexConfig> {
// TODO(mkaput): Cache config locally, honouring ETag and Last-Modified headers.

async fn load(source_id: SourceId, config: &Config) -> Result<IndexConfig> {
let index_config_url = source_id
.url
.join(IndexConfig::WELL_KNOWN_PATH)
.expect("Registry config URL should always be valid.");
debug!("fetching registry config: {index_config_url}");

let index_config = config
.http()?
.get(index_config_url)
.send()
.await?
.error_for_status()?
mkaput marked this conversation as resolved.
Show resolved Hide resolved
.json::<IndexConfig>()
.await?;

trace!(index_config = %serde_json::to_string(&index_config).unwrap());

Ok(index_config)
}

self.cached_index_config
.get_or_try_init(|| load(self.source_id, self.config))
.await
.context("failed to fetch registry config")
}
}

#[async_trait]
impl<'c> RegistryClient for HttpRegistryClient<'c> {
fn is_offline(&self) -> bool {
false
}

async fn get_records(&self, package: PackageName) -> Result<Option<Arc<IndexRecords>>> {
let index_config = self.index_config().await?;
let records_url = index_config.index.expand(package.into())?;

let response = self
.config
.http()?
.get(records_url)
.send()
.await?
.error_for_status();

if let Err(err) = &response {
if let Some(status) = err.status() {
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
}
}

let records = response?
.json()
.await
.context("failed to deserialize index records")?;

Ok(Some(Arc::new(records)))
}

async fn is_downloaded(&self, _package: PackageId) -> bool {
// TODO(mkaput): Cache downloaded packages.
false
}

async fn download(&self, package: PackageId) -> Result<PathBuf> {
let dl_url = self.index_config().await?.dl.expand(package.into())?;

let response = self
.config
.http()?
.get(dl_url)
.send()
.await?
.error_for_status()?;

let output_path = self.dl_fs.path_existent()?.join(package.tarball_name());
let output_file = OpenOptions::new()
.read(true)
.write(true)
.truncate(true)
.create(true)
.open(&output_path)
.await
.with_context(|| format!("failed to open: {output_path}"))?;

output_file
.lock_exclusive()
.with_context(|| format!("failed to lock file: {output_path}"))?;

let mut stream = response.bytes_stream();
let mut writer = BufWriter::new(output_file);
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("failed to read response chunk")?;
io::copy_buf(&mut &*chunk, &mut writer)
.await
.context("failed to save response chunk on disk")?;
}

Ok(output_path.into_std_path_buf())
}

async fn supports_publish(&self) -> Result<bool> {
// TODO(mkaput): Publishing to HTTP registries is not implemented yet.
Ok(false)
}

async fn publish(&self, _package: Package, _tarball: FileLockGuard) -> Result<()> {
todo!("Publishing to HTTP registries is not implemented yet.")
}
}
1 change: 1 addition & 0 deletions scarb/src/core/registry/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::core::registry::index::IndexRecords;
use crate::core::{Package, PackageId, PackageName};
use crate::flock::FileLockGuard;

pub mod http;
pub mod local;

#[async_trait]
Expand Down
4 changes: 4 additions & 0 deletions scarb/src/core/registry/index/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ pub struct IndexConfig {
pub index: TemplateUrl,
}

impl IndexConfig {
pub const WELL_KNOWN_PATH: &'_ str = "config.json";
}

#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(into = "u8", try_from = "u8")]
pub struct IndexVersion;
Expand Down
28 changes: 20 additions & 8 deletions scarb/src/sources/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ use std::collections::HashSet;
use std::fmt;
use std::path::PathBuf;

use anyhow::{bail, ensure, Context, Result};
use anyhow::{anyhow, bail, ensure, Context, Result};
use async_trait::async_trait;
use tracing::trace;

use scarb_ui::components::Status;

use crate::core::registry::client::http::HttpRegistryClient;
use crate::core::registry::client::local::LocalRegistryClient;
use crate::core::registry::client::RegistryClient;
use crate::core::registry::index::IndexRecord;
Expand Down Expand Up @@ -45,14 +46,25 @@ impl<'c> RegistrySource<'c> {

pub fn create_client(
source_id: SourceId,
_config: &'c Config,
config: &'c Config,
) -> Result<Box<dyn RegistryClient + 'c>> {
if let Ok(path) = source_id.url.to_file_path() {
trace!("creating local registry client for: {source_id}");
Ok(Box::new(LocalRegistryClient::new(&path)?))
} else {
// TODO(mkaput): Implement pipelining HTTP client.
bail!("unsupported registry protocol: {source_id}")
assert!(source_id.is_registry());
match source_id.url.scheme() {
"file" => {
trace!("creating local registry client for: {source_id}");
let path = source_id
.url
.to_file_path()
.map_err(|_| anyhow!("url is not a valid path: {}", source_id.url))?;
Ok(Box::new(LocalRegistryClient::new(&path)?))
}
"http" | "https" => {
trace!("creating http registry client for: {source_id}");
Ok(Box::new(HttpRegistryClient::new(source_id, config)?))
}
_ => {
bail!("unsupported registry protocol: {source_id}")
}
}
}
}
Expand Down
102 changes: 102 additions & 0 deletions scarb/tests/http_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use assert_fs::prelude::*;
use assert_fs::TempDir;
use indoc::indoc;
use std::fs;
use std::time::Duration;

use scarb_test_support::command::Scarb;
use scarb_test_support::project_builder::{Dep, DepBuilder, ProjectBuilder};
use scarb_test_support::registry::http::HttpRegistry;

#[test]
fn usage() {
let mut registry = HttpRegistry::serve();
registry.publish(|t| {
ProjectBuilder::start()
.name("bar")
.version("1.0.0")
.lib_cairo(r#"fn f() -> felt252 { 0 }"#)
.build(t);
});

let t = TempDir::new().unwrap();
ProjectBuilder::start()
.name("foo")
.version("0.1.0")
.dep("bar", Dep.version("1").registry(&registry))
.lib_cairo(r#"fn f() -> felt252 { bar::f() }"#)
.build(&t);

// FIXME(mkaput): Why are verbose statuses not appearing here?
Scarb::quick_snapbox()
.arg("fetch")
.current_dir(&t)
.timeout(Duration::from_secs(10))
.assert()
.success()
.stdout_matches(indoc! {r#"
[..] Downloading bar v1.0.0 ([..])
"#});
}

#[test]
fn not_found() {
let mut registry = HttpRegistry::serve();
registry.publish(|t| {
// Publish a package so that the directory hierarchy is created.
// Note, however, that we declare a dependency on baZ.
ProjectBuilder::start()
.name("bar")
.version("1.0.0")
.lib_cairo(r#"fn f() -> felt252 { 0 }"#)
.build(t);
});

let t = TempDir::new().unwrap();
ProjectBuilder::start()
.name("foo")
.version("0.1.0")
.dep("baz", Dep.version("1").registry(&registry))
.build(&t);

Scarb::quick_snapbox()
.arg("fetch")
.current_dir(&t)
.timeout(Duration::from_secs(10))
.assert()
.failure()
.stdout_matches(indoc! {r#"
error: package not found in registry: baz ^1 (registry+http://[..])
"#});
}

#[test]
fn missing_config_json() {
let registry = HttpRegistry::serve();
fs::remove_file(registry.child("config.json")).unwrap();

let t = TempDir::new().unwrap();
ProjectBuilder::start()
.name("foo")
.version("0.1.0")
.dep("baz", Dep.version("1").registry(&registry))
.build(&t);

Scarb::quick_snapbox()
.arg("fetch")
.current_dir(&t)
.timeout(Duration::from_secs(10))
.assert()
.failure()
.stdout_matches(indoc! {r#"
error: failed to lookup for `baz ^1 (registry+http://[..])` in registry: registry+http://[..]

Caused by:
0: failed to fetch registry config
1: HTTP status client error (404 Not Found) for url (http://[..]/config.json)
"#});
}

// TODO(mkaput): Test errors properly when package is in index, but tarball is missing.
// TODO(mkaput): Test interdependencies.
// TODO(mkaput): Test offline mode.
Loading