diff --git a/AGENTS.md b/AGENTS.md index 70cc2d6350d..cbd2fb4a082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,6 @@ - `/src/tests/` - Backend integration tests with snapshot testing using `insta` - `/src/config/` - Configuration loading and validation - `/src/util/` - Shared utilities (errors, authentication, pagination) - - `/src/views.rs` - Response serialization - `/app/` - Frontend Ember.js application - `/app/components/` - Reusable UI components (80+ components with scoped CSS files) - `/app/routes/` and `/app/controllers/` - Route handlers and data loading @@ -19,6 +18,7 @@ - `/app/adapters/`, `/app/serializers/` - Ember Data adapter layer - `/app/services/` - Shared services (session, notifications, API client) - `/crates/` - Workspace crates providing specialized functionality + - `crates_io_api_types/` - API response serialization types - `crates_io_database/` - Database models and schema (Diesel ORM) - `crates_io_worker/` - Background job queue system - `crates_io_index/` - Git index management for crate metadata diff --git a/Cargo.lock b/Cargo.lock index 9786c80ebc2..8f961f6a87e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,6 +1415,7 @@ dependencies = [ "clap", "colored 3.0.0", "cookie", + "crates_io_api_types", "crates_io_cdn_logs", "crates_io_database", "crates_io_database_dump", @@ -1498,6 +1499,22 @@ dependencies = [ "zip", ] +[[package]] +name = "crates_io_api_types" +version = "0.0.0" +dependencies = [ + "chrono", + "claims", + "crates_io_database", + "indexmap", + "semver", + "sentry-core", + "serde", + "serde_json", + "url", + "utoipa", +] + [[package]] name = "crates_io_cdn_logs" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index abb34176982..58ddca5eb8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ bigdecimal = { version = "=0.4.8", features = ["serde"] } bon = "=3.8.1" cargo-manifest = "=0.19.1" colored = "=3.0.0" +crates_io_api_types = { path = "crates/crates_io_api_types" } crates_io_cdn_logs = { path = "crates/crates_io_cdn_logs" } crates_io_database = { path = "crates/crates_io_database" } crates_io_database_dump = { path = "crates/crates_io_database_dump" } diff --git a/crates/crates_io_api_types/Cargo.toml b/crates/crates_io_api_types/Cargo.toml new file mode 100644 index 00000000000..a5bb101c84e --- /dev/null +++ b/crates/crates_io_api_types/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "crates_io_api_types" +version = "0.0.0" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-lang/crates.io" +description = "API response types for crates.io" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +chrono = { version = "=0.4.42", default-features = false, features = ["serde"] } +crates_io_database = { path = "../crates_io_database" } +indexmap = { version = "=2.11.4", features = ["serde"] } +semver = { version = "=1.0.27", features = ["serde"] } +sentry-core = "=0.45.0" +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.145" +url = "=2.5.7" +utoipa = { version = "=5.4.0", features = ["chrono"] } + +[dev-dependencies] +claims = "=0.8.0" diff --git a/crates/crates_io_api_types/README.md b/crates/crates_io_api_types/README.md new file mode 100644 index 00000000000..ba94ad745b3 --- /dev/null +++ b/crates/crates_io_api_types/README.md @@ -0,0 +1,12 @@ +# crates_io_api_types + +This crate contains some of the shared API response and request types used by the crates.io API. + +These types are serialized to/from JSON and represent the public API surface that clients interact with. They are distinct from the database models in `crates_io_database`, which represent the internal database schema. + +The crate includes types for publishing crates, trusted publishing configuration, release tracking metadata, and various encodable domain objects (crates, versions, users, teams, categories, keywords). + +## Design principles + +- **No business logic**: Types are primarily data structures with minimal behavior beyond serialization +- **OpenAPI schema**: Types include `utoipa::ToSchema` derives for automatic OpenAPI documentation generation diff --git a/src/external_urls.rs b/crates/crates_io_api_types/src/external_urls.rs similarity index 100% rename from src/external_urls.rs rename to crates/crates_io_api_types/src/external_urls.rs diff --git a/src/views/krate_publish.rs b/crates/crates_io_api_types/src/krate_publish.rs similarity index 94% rename from src/views/krate_publish.rs rename to crates/crates_io_api_types/src/krate_publish.rs index bb38e5376b2..dccddd001c7 100644 --- a/src/views/krate_publish.rs +++ b/crates/crates_io_api_types/src/krate_publish.rs @@ -3,10 +3,9 @@ //! to and from structs. The serializing is only utilised in //! integration tests. +use crates_io_database::models::DependencyKind; use serde::{Deserialize, Serialize}; -use crate::models::DependencyKind; - #[derive(Deserialize, Serialize, Debug)] pub struct PublishMetadata { pub name: String, diff --git a/src/views.rs b/crates/crates_io_api_types/src/lib.rs similarity index 99% rename from src/views.rs rename to crates/crates_io_api_types/src/lib.rs index fa7112eadb9..132e67895c5 100644 --- a/src/views.rs +++ b/crates/crates_io_api_types/src/lib.rs @@ -1,14 +1,18 @@ -use crate::external_urls::remove_blocked_urls; -use crate::models::{ +mod external_urls; +pub mod krate_publish; +pub mod release_tracks; +pub mod trustpub; + +pub use self::external_urls::remove_blocked_urls; +pub use self::krate_publish::{EncodableCrateDependency, PublishMetadata}; + +use chrono::{DateTime, Utc}; +use crates_io_database::models::{ ApiToken, Category, Crate, Dependency, DependencyKind, Keyword, Owner, ReverseDependency, Team, TopVersions, TrustpubData, User, Version, VersionDownload, VersionOwnerAction, }; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -pub mod krate_publish; -pub use self::krate_publish::{EncodableCrateDependency, PublishMetadata}; - #[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] #[schema(as = Category)] pub struct EncodableCategory { @@ -397,7 +401,7 @@ impl EncodableCrate { let default_version = default_version.map(ToString::to_string); if default_version.is_none() { let message = format!("Crate `{name}` has no default version"); - sentry::capture_message(&message, sentry::Level::Info); + sentry_core::capture_message(&message, sentry_core::Level::Info); } let yanked = yanked.unwrap_or_default(); diff --git a/crates/crates_io_api_types/src/release_tracks.rs b/crates/crates_io_api_types/src/release_tracks.rs new file mode 100644 index 00000000000..f86b49cd418 --- /dev/null +++ b/crates/crates_io_api_types/src/release_tracks.rs @@ -0,0 +1,177 @@ +use indexmap::IndexMap; +use serde::Serialize; + +#[derive(Debug, Eq, PartialEq, Serialize)] +pub struct ReleaseTracks(IndexMap); + +impl ReleaseTracks { + // Return the release tracks based on a sorted semver versions iterator (in descending order). + // **Remember to** filter out yanked versions manually before calling this function. + pub fn from_sorted_semver_iter<'a, I>(versions: I) -> Self + where + I: Iterator, + { + let mut map = IndexMap::new(); + for num in versions.filter(|num| num.pre.is_empty()) { + let key = ReleaseTrackName::from_semver(num); + let prev = map.last(); + if prev.filter(|&(k, _)| *k == key).is_none() { + map.insert( + key, + ReleaseTrackDetails { + highest: num.clone(), + }, + ); + } + } + + Self(map) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ReleaseTrackName { + Minor(u64), + Major(u64), +} + +impl ReleaseTrackName { + pub fn from_semver(version: &semver::Version) -> Self { + if version.major == 0 { + Self::Minor(version.minor) + } else { + Self::Major(version.major) + } + } +} + +impl std::fmt::Display for ReleaseTrackName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Minor(minor) => write!(f, "0.{minor}"), + Self::Major(major) => write!(f, "{major}"), + } + } +} + +impl serde::Serialize for ReleaseTrackName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + Self: std::fmt::Display, + { + serializer.collect_str(self) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +pub struct ReleaseTrackDetails { + pub highest: semver::Version, +} + +#[cfg(test)] +mod tests { + use super::{ReleaseTrackDetails, ReleaseTrackName, ReleaseTracks}; + use indexmap::IndexMap; + use serde_json::json; + + #[track_caller] + fn version(str: &str) -> semver::Version { + semver::Version::parse(str).unwrap() + } + + #[test] + fn release_tracks_empty() { + let versions = []; + assert_eq!( + ReleaseTracks::from_sorted_semver_iter(versions.into_iter()), + ReleaseTracks(IndexMap::new()) + ); + } + + #[test] + fn release_tracks_prerelease() { + let versions = [version("1.0.0-beta.5")]; + assert_eq!( + ReleaseTracks::from_sorted_semver_iter(versions.iter()), + ReleaseTracks(IndexMap::new()) + ); + } + + #[test] + fn release_tracks_multiple() { + let versions = [ + "100.1.1", + "100.1.0", + "1.3.5", + "1.2.5", + "1.1.5", + "0.4.0-rc.1", + "0.3.23", + "0.3.22", + "0.3.21-pre.0", + "0.3.20", + "0.3.3", + "0.3.2", + "0.3.1", + "0.3.0", + "0.2.1", + "0.2.0", + "0.1.2", + "0.1.1", + ] + .map(version); + + let release_tracks = ReleaseTracks::from_sorted_semver_iter(versions.iter()); + assert_eq!( + release_tracks, + ReleaseTracks(IndexMap::from([ + ( + ReleaseTrackName::Major(100), + ReleaseTrackDetails { + highest: version("100.1.1") + } + ), + ( + ReleaseTrackName::Major(1), + ReleaseTrackDetails { + highest: version("1.3.5") + } + ), + ( + ReleaseTrackName::Minor(3), + ReleaseTrackDetails { + highest: version("0.3.23") + } + ), + ( + ReleaseTrackName::Minor(2), + ReleaseTrackDetails { + highest: version("0.2.1") + } + ), + ( + ReleaseTrackName::Minor(1), + ReleaseTrackDetails { + highest: version("0.1.2") + } + ), + ])) + ); + + let json = serde_json::from_str::( + &serde_json::to_string(&release_tracks).unwrap(), + ) + .unwrap(); + assert_eq!( + json, + json!({ + "100": { "highest": "100.1.1" }, + "1": { "highest": "1.3.5" }, + "0.3": { "highest": "0.3.23" }, + "0.2": { "highest": "0.2.1" }, + "0.1": { "highest": "0.1.2" } + }) + ); + } +} diff --git a/crates/crates_io_api_types/src/trustpub.rs b/crates/crates_io_api_types/src/trustpub.rs new file mode 100644 index 00000000000..626de5e0f0a --- /dev/null +++ b/crates/crates_io_api_types/src/trustpub.rs @@ -0,0 +1,39 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[schema(as = GitHubConfig)] +pub struct GitHubConfig { + #[schema(example = 42)] + pub id: i32, + #[schema(example = "regex")] + #[serde(rename = "crate")] + pub krate: String, + #[schema(example = "rust-lang")] + pub repository_owner: String, + #[schema(example = 5430905)] + pub repository_owner_id: i32, + #[schema(example = "regex")] + pub repository_name: String, + #[schema(example = "ci.yml")] + pub workflow_filename: String, + #[schema(example = json!(null))] + pub environment: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +#[schema(as = NewGitHubConfig)] +pub struct NewGitHubConfig { + #[schema(example = "regex")] + #[serde(rename = "crate")] + pub krate: String, + #[schema(example = "rust-lang")] + pub repository_owner: String, + #[schema(example = "regex")] + pub repository_name: String, + #[schema(example = "ci.yml")] + pub workflow_filename: String, + #[schema(example = json!(null))] + pub environment: Option, +} diff --git a/src/controllers/krate/versions.rs b/src/controllers/krate/versions.rs index 7c221d3af19..60d1ba043cf 100644 --- a/src/controllers/krate/versions.rs +++ b/src/controllers/krate/versions.rs @@ -11,6 +11,7 @@ use crate::util::RequestUtils; use crate::util::errors::{AppResult, BoxedAppError, bad_request}; use crate::util::string_excl_null::StringExclNull; use crate::views::EncodableVersion; +use crate::views::release_tracks::ReleaseTracks; use axum::Json; use axum::extract::FromRequestParts; use axum_extra::extract::Query; @@ -337,74 +338,6 @@ struct ResponseMeta { release_tracks: Option, } -#[derive(Debug, Eq, PartialEq, Serialize)] -struct ReleaseTracks(IndexMap); - -impl ReleaseTracks { - // Return the release tracks based on a sorted semver versions iterator (in descending order). - // **Remember to** filter out yanked versions manually before calling this function. - pub fn from_sorted_semver_iter<'a, I>(versions: I) -> Self - where - I: Iterator, - { - let mut map = IndexMap::new(); - for num in versions.filter(|num| num.pre.is_empty()) { - let key = ReleaseTrackName::from_semver(num); - let prev = map.last(); - if prev.filter(|&(k, _)| *k == key).is_none() { - map.insert( - key, - ReleaseTrackDetails { - highest: num.clone(), - }, - ); - } - } - - Self(map) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -enum ReleaseTrackName { - Minor(u64), - Major(u64), -} - -impl ReleaseTrackName { - pub fn from_semver(version: &semver::Version) -> Self { - if version.major == 0 { - Self::Minor(version.minor) - } else { - Self::Major(version.major) - } - } -} - -impl std::fmt::Display for ReleaseTrackName { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Minor(minor) => write!(f, "0.{minor}"), - Self::Major(major) => write!(f, "{major}"), - } - } -} - -impl serde::Serialize for ReleaseTrackName { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - Self: std::fmt::Display, - { - serializer.collect_str(self) - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize)] -struct ReleaseTrackDetails { - highest: semver::Version, -} - #[derive(Debug, Default)] struct ShowIncludeMode { release_tracks: bool, @@ -432,110 +365,3 @@ impl FromStr for ShowIncludeMode { Ok(mode) } } - -#[cfg(test)] -mod tests { - use super::{ReleaseTrackDetails, ReleaseTrackName, ReleaseTracks}; - use indexmap::IndexMap; - use serde_json::json; - - #[track_caller] - fn version(str: &str) -> semver::Version { - semver::Version::parse(str).unwrap() - } - - #[test] - fn release_tracks_empty() { - let versions = []; - assert_eq!( - ReleaseTracks::from_sorted_semver_iter(versions.into_iter()), - ReleaseTracks(IndexMap::new()) - ); - } - - #[test] - fn release_tracks_prerelease() { - let versions = [version("1.0.0-beta.5")]; - assert_eq!( - ReleaseTracks::from_sorted_semver_iter(versions.iter()), - ReleaseTracks(IndexMap::new()) - ); - } - - #[test] - fn release_tracks_multiple() { - let versions = [ - "100.1.1", - "100.1.0", - "1.3.5", - "1.2.5", - "1.1.5", - "0.4.0-rc.1", - "0.3.23", - "0.3.22", - "0.3.21-pre.0", - "0.3.20", - "0.3.3", - "0.3.2", - "0.3.1", - "0.3.0", - "0.2.1", - "0.2.0", - "0.1.2", - "0.1.1", - ] - .map(version); - - let release_tracks = ReleaseTracks::from_sorted_semver_iter(versions.iter()); - assert_eq!( - release_tracks, - ReleaseTracks(IndexMap::from([ - ( - ReleaseTrackName::Major(100), - ReleaseTrackDetails { - highest: version("100.1.1") - } - ), - ( - ReleaseTrackName::Major(1), - ReleaseTrackDetails { - highest: version("1.3.5") - } - ), - ( - ReleaseTrackName::Minor(3), - ReleaseTrackDetails { - highest: version("0.3.23") - } - ), - ( - ReleaseTrackName::Minor(2), - ReleaseTrackDetails { - highest: version("0.2.1") - } - ), - ( - ReleaseTrackName::Minor(1), - ReleaseTrackDetails { - highest: version("0.1.2") - } - ), - ])) - ); - - let json = serde_json::from_str::( - &serde_json::to_string(&release_tracks).unwrap(), - ) - .unwrap(); - assert_eq!( - json, - json!({ - "100": { "highest": "100.1.1" }, - "1": { "highest": "1.3.5" }, - "0.3": { "highest": "0.3.23" }, - "0.2": { "highest": "0.2.1" }, - "0.1": { "highest": "0.1.2" } - }) - ); - } -} diff --git a/src/controllers/trustpub/github_configs/json.rs b/src/controllers/trustpub/github_configs/json.rs index 376cdabcb9f..40902eba98a 100644 --- a/src/controllers/trustpub/github_configs/json.rs +++ b/src/controllers/trustpub/github_configs/json.rs @@ -1,42 +1,8 @@ use axum::Json; use axum::extract::FromRequest; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct GitHubConfig { - #[schema(example = 42)] - pub id: i32, - #[schema(example = "regex")] - #[serde(rename = "crate")] - pub krate: String, - #[schema(example = "rust-lang")] - pub repository_owner: String, - #[schema(example = 5430905)] - pub repository_owner_id: i32, - #[schema(example = "regex")] - pub repository_name: String, - #[schema(example = "ci.yml")] - pub workflow_filename: String, - #[schema(example = json!(null))] - pub environment: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Deserialize, utoipa::ToSchema)] -pub struct NewGitHubConfig { - #[schema(example = "regex")] - #[serde(rename = "crate")] - pub krate: String, - #[schema(example = "rust-lang")] - pub repository_owner: String, - #[schema(example = "regex")] - pub repository_name: String, - #[schema(example = "ci.yml")] - pub workflow_filename: String, - #[schema(example = json!(null))] - pub environment: Option, -} +pub use crate::views::trustpub::{GitHubConfig, NewGitHubConfig}; #[derive(Debug, Deserialize, FromRequest, utoipa::ToSchema)] #[from_request(via(Json))] diff --git a/src/lib.rs b/src/lib.rs index 9f4515944e1..1af04f03bf7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! [krate](krate/index.html), [user](user/index.html) and [version](version/index.html) modules. pub use crate::{app::App, email::Emails}; +pub use crates_io_api_types as views; pub use crates_io_database::{models, schema}; use std::sync::Arc; @@ -26,7 +27,6 @@ pub mod config; pub mod controllers; pub mod db; pub mod email; -pub mod external_urls; pub mod fastly; pub mod headers; pub mod index; @@ -46,7 +46,6 @@ pub mod tasks; pub mod tests; pub mod typosquat; pub mod util; -pub mod views; pub mod worker; /// Used for setting different values depending on whether the app is being run in production,