Skip to content

Commit

Permalink
Merge pull request #26 from FuelLabs/sophie/poll-api
Browse files Browse the repository at this point in the history
feat: Package updates API for explorer indexer
  • Loading branch information
sdankel authored Jan 16, 2025
2 parents e17de78 + 4597d36 commit efd3abe
Show file tree
Hide file tree
Showing 13 changed files with 459 additions and 34 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_URI}/$
GITHUB_CLIENT_SECRET=""

# IPFS env
PINATA_URL="https://gateway.pinata.cloud"
PINATA_API_KEY=""
PINATA_API_SECRET=""
PINATA_JWT=""
4 changes: 2 additions & 2 deletions app/src/features/dahboard/components/PackageDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ const PackageDashboard: React.FC = () => {
<Container maxWidth='md' style={{ marginTop: '24px' }}>
<Grid container spacing={4}>
<Grid item xs={12} md={6}>
{renderPackages(data.recently_updated, 'Just Updated')}
{renderPackages(data.recentlyUpdated, 'Just Updated')}
</Grid>
<Grid item xs={12} md={6}>
{renderPackages(data.recently_created, 'New Packages')}
{renderPackages(data.recentlyCreated, 'New Packages')}
</Grid>
</Grid>
</Container>
Expand Down
8 changes: 4 additions & 4 deletions app/src/features/dahboard/hooks/useFetchRecentPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ export interface RecentPackage {
}

export interface RecentPackagesResponse {
recently_updated: RecentPackage[];
recently_created: RecentPackage[];
recentlyUpdated: RecentPackage[];
recentlyCreated: RecentPackage[];
}

const useFetchRecentPackages = () => {
const [data, setData] = useState<RecentPackagesResponse>({
recently_updated: [],
recently_created: [],
recentlyUpdated: [],
recentlyCreated: [],
});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
Expand Down
4 changes: 2 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
pub mod api_token;
pub mod auth;
pub mod pagination;
pub mod publish;
pub mod search;

use std::io::Cursor;

use rocket::{
http::{ContentType, Status},
response::Responder,
serde::{json::Json, Serialize},
Request,
};
use serde_json::json;
use std::io::Cursor;
use thiserror::Error;
use tracing::error;

Expand Down
32 changes: 32 additions & 0 deletions src/api/pagination.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use rocket::FromForm;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, FromForm, Clone, Debug)]
pub struct Pagination {
pub page: Option<i64>,
pub per_page: Option<i64>,
}

impl Pagination {
pub fn page(&self) -> i64 {
self.page.unwrap_or(1)
}

pub fn limit(&self) -> i64 {
self.per_page.unwrap_or(10).max(1) // Default to 10 per page
}

pub fn offset(&self) -> i64 {
(self.page() - 1) * self.limit()
}
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedResponse<T> {
pub data: Vec<T>,
pub total_count: i64,
pub total_pages: i64,
pub current_page: i64,
pub per_page: i64,
}
20 changes: 20 additions & 0 deletions src/api/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ fn is_valid_package_name(name: &str) -> bool {
re.is_match(name)
}

fn is_valid_package_version(name: &str) -> bool {
// Must start with an alphanumeric character, can contain only letters, numbers, underscores, dots and hyphens
let re = Regex::new(r"^[a-zA-Z0-9][\w.-]*$").unwrap();
re.is_match(name)
}

/// The publish request.
#[derive(Deserialize, Debug)]
pub struct PublishRequest {
#[serde(deserialize_with = "validate_package_name")]
pub package_name: String,
pub upload_id: Uuid,
#[serde(deserialize_with = "validate_package_version")]
pub num: String,
pub package_description: Option<String>,
pub repository: Option<Url>,
Expand All @@ -39,6 +46,19 @@ where
Ok(name)
}

fn validate_package_version<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let name = String::deserialize(deserializer)?;
if !is_valid_package_version(&name) {
return Err(serde::de::Error::custom(
"Package version must start with an alphanumeric character, can contain only letters, numbers, underscores, dots and hyphens",
));
}
Ok(name)
}

/// The response to an upload_project request.
#[derive(Serialize, Debug)]
pub struct UploadResponse {
Expand Down
65 changes: 64 additions & 1 deletion src/api/search.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,71 @@
use crate::models::PackagePreview;
use crate::{
models::PackagePreview,
pinata::{ipfs_hash_to_abi_url, ipfs_hash_to_tgz_url},
};
use serde::Serialize;
use url::Url;

#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RecentPackagesResponse {
pub recently_created: Vec<PackagePreview>,
pub recently_updated: Vec<PackagePreview>,
}

#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct FullPackage {
#[serde(flatten)]
pub package_preview: PackagePreview,

// Metadata from Uploads table
pub bytecode_identifier: Option<String>,
pub forc_version: String,

// IPFS URLs
pub source_code_ipfs_url: String,
pub abi_ipfs_url: Option<String>,

// Version Metadata
pub repository: Option<Url>,
pub documentation: Option<Url>,
pub homepage: Option<Url>,
pub urls: Vec<Url>,
pub readme: Option<String>,
pub license: Option<String>,
}

impl From<crate::models::FullPackage> for FullPackage {
fn from(full_package: crate::models::FullPackage) -> Self {
fn string_to_url(s: String) -> Option<Url> {
Url::parse(&s).ok()
}

FullPackage {
package_preview: PackagePreview {
name: full_package.name,
version: full_package.version,
description: full_package.description,
created_at: full_package.created_at,
updated_at: full_package.updated_at,
},
bytecode_identifier: full_package.bytecode_identifier,
forc_version: full_package.forc_version,
source_code_ipfs_url: ipfs_hash_to_tgz_url(&full_package.source_code_ipfs_hash),
abi_ipfs_url: full_package
.abi_ipfs_hash
.map(|hash| ipfs_hash_to_abi_url(&hash)),
repository: full_package.repository.and_then(string_to_url),
documentation: full_package.documentation.and_then(string_to_url),
homepage: full_package.homepage.and_then(string_to_url),
urls: full_package
.urls
.into_iter()
.flatten()
.filter_map(string_to_url)
.collect(),
license: full_package.license,
readme: full_package.readme,
}
}
}
137 changes: 135 additions & 2 deletions src/db/package_version.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use super::error::DatabaseError;
use super::{models, schema, DbConn};
use crate::api::pagination::{PaginatedResponse, Pagination};
use crate::api::publish::PublishRequest;
use crate::models::{ApiToken, PackagePreview};
use crate::models::{ApiToken, CountResult, FullPackage, PackagePreview};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use diesel::sql_types::Timestamptz;
use uuid::Uuid;

impl DbConn {
Expand Down Expand Up @@ -139,7 +142,7 @@ impl DbConn {
Ok(packages)
}

/// Fetch the most recently created packages.
/// Fetch the [PackagePreview]s of the most recently created packages.
pub fn get_recently_created(&mut self) -> Result<Vec<PackagePreview>, DatabaseError> {
let packages = diesel::sql_query(
r#"WITH ranked_versions AS (
Expand Down Expand Up @@ -171,4 +174,134 @@ impl DbConn {

Ok(packages)
}

/// Fetch the [FullPackage]s of packages matching the given parameters.
pub fn get_full_packages(
&mut self,
updated_after: Option<DateTime<Utc>>,
pagination: Pagination,
) -> Result<PaginatedResponse<FullPackage>, DatabaseError> {
let page = pagination.page();
let limit = pagination.limit();
let offset = pagination.offset();

// Query total count
let total_count: i64 = diesel::sql_query(
r#"
SELECT COUNT(*) AS count
FROM packages p
INNER JOIN package_versions pv ON pv.package_id = p.id
INNER JOIN uploads u ON pv.upload_id = u.id
WHERE ($1 IS NULL OR pv.created_at > $1)
"#,
)
.bind::<diesel::sql_types::Nullable<Timestamptz>, _>(updated_after)
.get_result::<CountResult>(self.inner())
.map_err(|err| DatabaseError::QueryFailed("full packages count".to_string(), err))?
.count;

// Query paginated data
let data = diesel::sql_query(
r#"
SELECT
p.package_name AS name,
pv.num AS version,
pv.package_description AS description,
p.created_at AS created_at,
pv.created_at AS updated_at,
u.bytecode_identifier AS bytecode_identifier,
u.forc_version AS forc_version,
u.source_code_ipfs_hash AS source_code_ipfs_hash,
u.abi_ipfs_hash AS abi_ipfs_hash,
pv.repository AS repository,
pv.documentation AS documentation,
pv.homepage AS homepage,
pv.urls AS urls,
pv.readme AS readme,
pv.license AS license
FROM
packages p
INNER JOIN
package_versions pv ON pv.package_id = p.id
INNER JOIN
uploads u ON pv.upload_id = u.id
WHERE
($1 IS NULL OR pv.created_at > $1) -- Optional date filter
ORDER BY
pv.created_at DESC
LIMIT $2
OFFSET $3
"#,
)
.bind::<diesel::sql_types::Nullable<Timestamptz>, _>(updated_after)
.bind::<diesel::sql_types::BigInt, _>(limit)
.bind::<diesel::sql_types::BigInt, _>(offset)
.load::<FullPackage>(self.inner())
.map_err(|err| DatabaseError::QueryFailed("full packages".to_string(), err))?;

// Calculate total pages
let total_pages = (total_count as f64 / limit as f64).ceil() as i64;

Ok(PaginatedResponse {
data,
total_count,
total_pages,
current_page: page,
per_page: limit,
})
}

/// Fetch the [FullPackage] for the given package name and version string.
pub fn get_full_package_version(
&mut self,
pkg_name: String,
version: String,
) -> Result<FullPackage, DatabaseError> {
let data = diesel::sql_query(
r#"
SELECT
p.package_name AS name,
pv.num AS version,
pv.package_description AS description,
p.created_at AS created_at,
pv.created_at AS updated_at,
u.bytecode_identifier AS bytecode_identifier,
u.forc_version AS forc_version,
u.source_code_ipfs_hash AS source_code_ipfs_hash,
u.abi_ipfs_hash AS abi_ipfs_hash,
pv.repository AS repository,
pv.documentation AS documentation,
pv.homepage AS homepage,
pv.urls AS urls,
pv.readme AS readme,
pv.license AS license
FROM
packages p
INNER JOIN
package_versions pv ON pv.package_id = p.id
INNER JOIN
uploads u ON pv.upload_id = u.id
WHERE
p.package_name = $1
-- If version is not specified, use the default_version of the package.
AND (pv.num = $2 OR ('' = $2 AND p.default_version = pv.id))
LIMIT 1
"#,
)
.bind::<diesel::sql_types::Text, _>(pkg_name.clone())
.bind::<diesel::sql_types::Text, _>(version.clone())
.load::<FullPackage>(self.inner())
.map_err(|err| DatabaseError::QueryFailed("full package version".to_string(), err))?;
let package = data.first().ok_or_else(|| {
DatabaseError::NotFound(format!("{pkg_name}@{version}"), diesel::NotFound)
})?;

Ok(package.clone())
}
}
Loading

0 comments on commit efd3abe

Please sign in to comment.