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

feat: API for uploading a sway project #19

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

sdankel
Copy link
Member

@sdankel sdankel commented Sep 24, 2024

Closes #18

This PR adds a new API, upload_project, that accepts a .tgz file containing a sway project and does the following:

  1. Strips out irrelevant files
  2. Attempts to build the project
  3. Uploads the ABI file IPFS and stores the hashes
  4. Generates a bytecode ID from the compiled binary and stores it in a new postgres table
  5. Creates a new .tgz from the cleaned up source code and uploads it to IPFS
  6. Returns an upload ID, which can be used to publish metadata about the uploaded project (coming in a future PR)

Notes:

  • Because of the limitations of pinata and the pinata/IPFS rust SDKs, the source code is stored in IPFS as a .tgz file rather than as a uncompressed directory.
  • In order to build the sway project with the correct version of forc, we pre-install the latest 20 versions of forc on the docker image. As a fallback, the service uses cargo binstall to fetch the version of forc if it isn't pre-installed. This means the API can take a while to run if the version requested is not already installed.
  • I added one integration test for the happy path, but I didn't want to add more with different versions because the test takes a while to run. Happy to add more tests as needed.

Copy link

vercel bot commented Sep 24, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
forc-pub ✅ Ready (Inspect) Visit Preview 💬 Add feedback Nov 21, 2024 5:29pm

@sdankel sdankel changed the title feat: API for file uploading feat: API for uploading a sway project Sep 25, 2024
@sdankel sdankel requested a review from a team November 21, 2024 17:32
@sdankel sdankel marked this pull request as ready for review November 21, 2024 17:32
}

impl<'r, 'o: 'r> Responder<'r, 'o> for ApiError {
fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> {
println!("API error: {}", self);
Copy link
Member

Choose a reason for hiding this comment

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

should we use println_error from the forc-tracing crate here so our console output is standardised?

Copy link
Member

@JoshuaBatty JoshuaBatty left a comment

Choose a reason for hiding this comment

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

Awesome work! just a couple of nits

Comment on lines +119 to +152
fs::create_dir_all(forc_path.clone()).unwrap();
let forc_path = fs::canonicalize(forc_path.clone()).unwrap();

install_forc_at_path(forc_version, &forc_path)?;

// Create an upload ID and temporary directory.
let upload_id = Uuid::new_v4();
let upload_dir_str = format!("tmp/uploads/{}", upload_id);
let upload_dir = Path::new(&upload_dir_str);

fs::create_dir_all(upload_dir).unwrap();

// Persist the file to disk.
let orig_tarball_path = upload_dir.join("original.tgz");
tarball
.persist_to(&orig_tarball_path)
.await
.map_err(|_| ApiError::Upload(UploadError::SaveFile))?;

// Handle the project upload and store the metadata in the database.
let upload = handle_project_upload(
upload_dir,
&upload_id,
&orig_tarball_path,
&forc_path,
forc_version.to_string(),
pinata_client.inner(),
)
.await?;

let _ = db.conn().insert_upload(&upload)?;

// Clean up the temporary directory.
fs::remove_dir_all(upload_dir).unwrap();
Copy link
Member

Choose a reason for hiding this comment

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

Can we introduce an error type that we can use instead of unwrapping on the filesystem calls?

Copy link
Member

Choose a reason for hiding this comment

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

or change these to an expect?

Comment on lines +67 to +87
#[derive(Queryable, Selectable, Debug, Clone)]
#[diesel(table_name = crate::schema::uploads)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Upload {
pub id: Uuid,
pub source_code_ipfs_hash: String,
pub forc_version: String,
pub abi_ipfs_hash: Option<String>,
pub bytecode_identifier: Option<String>,
pub created_at: SystemTime,
}

#[derive(Insertable, Debug)]
#[diesel(table_name = crate::schema::uploads)]
pub struct NewUpload {
pub id: Uuid,
pub source_code_ipfs_hash: String,
pub forc_version: String,
pub abi_ipfs_hash: Option<String>,
pub bytecode_identifier: Option<String>,
}
Copy link
Member

Choose a reason for hiding this comment

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

Maybe it's fine but my instinct here would be to try and only have 1 of these types considering they are duplicates apart from the final created_at field. Disregard if it doesn't make sense in the design though.

}
}

pub struct MockPinataClient;
Copy link
Member

Choose a reason for hiding this comment

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

Maybe a comment here explaining the intended purpose of the Mock Client?

Choose a reason for hiding this comment

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

Also this is only used for testing; although minimal - this code would still get compiled into the release binary.
Hence maybe worthwhile creating and putting this within the test macro/mod or co-located with the tests.

BytecodeId(String),
}

pub async fn handle_project_upload(
Copy link
Member

Choose a reason for hiding this comment

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

doc comment for this fn would be useful

let project_dir = upload_dir.join("project");

// Unpack the tarball.
let tarball = File::open(orig_tarball_path).unwrap();
Copy link
Member

Choose a reason for hiding this comment

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

a few unwraps in the fn that could be turned into expects

@JoshuaBatty JoshuaBatty requested a review from a team November 22, 2024 01:37
@@ -0,0 +1,8 @@
CREATE TABLE uploads (

Choose a reason for hiding this comment

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

would help with replayability and iterating/testing:

Suggested change
CREATE TABLE uploads (
CREATE TABLE IF NOT EXISTS uploads (

@@ -0,0 +1 @@
DROP TABLE uploads

Choose a reason for hiding this comment

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

nit: could also do same here:

Suggested change
DROP TABLE uploads
DROP TABLE IF EXISTS uploads


/// The response to an upload_project request.
#[derive(Serialize, Debug)]
pub struct UploadResponse {

Choose a reason for hiding this comment

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

nit?

Suggested change
pub struct UploadResponse {
pub struct PublishResponse {

Comment on lines +22 to +33
dotenv().ok();
match (env::var("PINATA_API_KEY"), env::var("PINATA_API_SECRET")) {
(Ok(api_key), Ok(secret_api_key)) => {
let api = PinataApi::new(api_key, secret_api_key)
.map_err(|_| UploadError::Authentication)?;
api.test_authentication()
.await
.map_err(|_| UploadError::Authentication)?;
Ok(PinataClientImpl { pinata_api: api })
}
_ => Err(UploadError::Ipfs),
}

Choose a reason for hiding this comment

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

nit: reduced nesting

Suggested change
dotenv().ok();
match (env::var("PINATA_API_KEY"), env::var("PINATA_API_SECRET")) {
(Ok(api_key), Ok(secret_api_key)) => {
let api = PinataApi::new(api_key, secret_api_key)
.map_err(|_| UploadError::Authentication)?;
api.test_authentication()
.await
.map_err(|_| UploadError::Authentication)?;
Ok(PinataClientImpl { pinata_api: api })
}
_ => Err(UploadError::Ipfs),
}
dotenv().ok();
let (api_key, secret_api_key) = match (env::var("PINATA_API_KEY"), env::var("PINATA_API_SECRET")) {
(Ok(key), Ok(secret)) => (key, secret),
_ => return Err(UploadError::Ipfs),
};
let api = PinataApi::new(api_key, secret_api_key)
.map_err(|_| UploadError::Authentication)?;
api.test_authentication()
.await
.map_err(|_| UploadError::Authentication)?;
Ok(PinataClientImpl { pinata_api: api })

Copy link

@alfiedotwtf alfiedotwtf Nov 22, 2024

Choose a reason for hiding this comment

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

I like both approaches 😅

With Sophie's it's compact which I'm a big fan of.

With Zeeshan's it's return early which I also a big fan of, but I've recently started trying to not leave variables dangling for the entire scope that aren't used (most of the time it's hard to do, but it's just a style that I've picked up lately).

Combining both approaches:

        let api = match (env::var("PINATA_API_KEY"), env::var("PINATA_API_SECRET")) {
            (Ok(api_key), Ok(secret_api_key)) => {
                PinataApi::new(api_key, secret_api_key)
                    .map_err(|_| UploadError::Authentication)?
            }
            _ => return Err(UploadError::Ipfs),
        };

        api.test_authentication()
            .await
            .map_err(|_| UploadError::Authentication)?;

        Ok(PinataClientImpl { pinata_api: api })

// Install the forc version if it's not already installed.
let forc_path_str = format!("forc-{forc_version}");
let forc_path = PathBuf::from(&forc_path_str);
fs::create_dir_all(forc_path.clone()).unwrap();

Choose a reason for hiding this comment

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

could also use the tempfile crate which handles creating temporary files and directories.
Not a fan of introducing external deps if not needed; but the good thing about the crate - and tempdir function is that if an error occurs anywhere in the function before the directory is removed, the directory would still be removed.
This is because the dir removal is based on the Drop trait impl - hence dir is removed as soon as the var goes out of scope.
^ this would ensure clean future runs of the function (assuming a previous run failed)

pub forc_version: String,
pub abi_ipfs_hash: Option<String>,
pub bytecode_identifier: Option<String>,
pub created_at: SystemTime,

Choose a reason for hiding this comment

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

SystemTime does not support timezones in the DB and is generally less ergonomic than time::OffsetDateTime; for persistent data I'd reccomend TZ aware datetime primitive.

Comment on lines +43 to +45
let unpacked_dir = upload_dir.join("unpacked");
let release_dir = unpacked_dir.join("out/release");
let project_dir = upload_dir.join("project");

Choose a reason for hiding this comment

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

nit: if you can foresee any re-usage of these strings; then worthwhile making these constants

archive.unpack(&unpacked_dir).unwrap();

// Remove `out` directory if it exists.
let _ = fs::remove_dir_all(unpacked_dir.join("out"));

Choose a reason for hiding this comment

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

nit: worthwhile using tempfile (based on previous comment)

.arg("--no-confirm")
.arg("--root")
.arg(forc_path)
.arg(format!("--pkg-url=https://github.com/FuelLabs/sway/releases/download/{forc_version}/forc-binaries-linux_arm64.tar.gz"))

Choose a reason for hiding this comment

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

May need to handle amd64 (x86) machine; you may need to match architectures beforehand

}

/// Installs the given version of forc at the specific root path using cargo-binstall.
pub fn install_forc_at_path(forc_version: &str, forc_path: &Path) -> Result<(), UploadError> {
Copy link

@zees-dev zees-dev Nov 22, 2024

Choose a reason for hiding this comment

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

Instead of using the CLI; i'd reccomend using library/SDK functions to perform uploading operations.
They would be much lighter and ephemeral as one would not need to have the binary installed.
^ could be a future PR; just something to keep in mind

Copy link

@zees-dev zees-dev Nov 22, 2024

Choose a reason for hiding this comment

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

would reccomend an .env.example file being committed in the repo; with the .env file ignored; and instructions in readme as quickstart:

cp .env.local .env

pinata_client: &State<PinataClientImpl>,
forc_version: &str,
mut tarball: TempFile<'_>,
) -> ApiResult<UploadResponse> {

Choose a reason for hiding this comment

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

I'm wondering if forc_version will need to be sanitised e.g could a request escape the working directory?

/upload_project?v1../../../../../tmp

@alfiedotwtf
Copy link

Nice! Happy to approve once updated and you're happy for another review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Upload project API
4 participants