diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 82c2bb598..9553c4744 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -802,66 +802,15 @@ pub enum ApiUpdateScopeRequest { Description(Option), } -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiStats { - pub newest: Vec, - pub updated: Vec, - pub featured: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiStatsPackage { - pub scope: ScopeName, - pub name: PackageName, -} - -impl From for ApiStatsPackage { - fn from(p: StatsPackage) -> Self { - Self { - scope: p.scope, - name: p.name, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiStatsPackageVersion { - pub scope: ScopeName, - pub package: PackageName, - pub version: Version, -} - -impl From for ApiStatsPackageVersion { - fn from(v: StatsPackageVersion) -> Self { - Self { - scope: v.scope, - package: v.name, - version: v.version, - } - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiMetrics { - pub packages: usize, - pub packages_1d: usize, - pub packages_7d: usize, - pub packages_30d: usize, - - pub users: usize, - pub users_1d: usize, - pub users_7d: usize, - pub users_30d: usize, - - pub package_versions: usize, - pub package_versions_1d: usize, - pub package_versions_7d: usize, - pub package_versions_30d: usize, -} +// `ApiStats`, `ApiStatsPackage`, `ApiStatsPackageVersion`, and `ApiMetrics` now +// live in the shared, wasm-safe `jsr_types` crate so the workers-rs front serves +// byte-identical JSON for the ported `GET /api/stats` and `GET /api/metrics` +// endpoints. Re-exported here so existing `crate::api::types::*` paths keep +// working. +pub use jsr_types::api::ApiMetrics; +pub use jsr_types::api::ApiStats; +pub use jsr_types::api::ApiStatsPackage; +pub use jsr_types::api::ApiStatsPackageVersion; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/jsr_types/src/api.rs b/crates/jsr_types/src/api.rs new file mode 100644 index 000000000..f71c53b3d --- /dev/null +++ b/crates/jsr_types/src/api.rs @@ -0,0 +1,80 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. + +//! Shared API wire types. +//! +//! These are the JSON response shapes served on `api.jsr.io`. They live in the +//! shared crate so both the Cloud Run compute service and the workers-rs front +//! serialize byte-identical responses (JSON parity), and so the Worker can +//! build them without depending on the native `api` crate. Only the wire types +//! that the Worker actually produces are moved here as their endpoints migrate; +//! the rest stay in `api/src/api/types.rs` for now. + +use serde::Deserialize; +use serde::Serialize; + +use crate::ids::PackageName; +use crate::ids::ScopeName; +use crate::ids::Version; +use crate::models::StatsPackage; +use crate::models::StatsPackageVersion; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiStats { + pub newest: Vec, + pub updated: Vec, + pub featured: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiStatsPackage { + pub scope: ScopeName, + pub name: PackageName, +} + +impl From for ApiStatsPackage { + fn from(p: StatsPackage) -> Self { + Self { + scope: p.scope, + name: p.name, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiStatsPackageVersion { + pub scope: ScopeName, + pub package: PackageName, + pub version: Version, +} + +impl From for ApiStatsPackageVersion { + fn from(v: StatsPackageVersion) -> Self { + Self { + scope: v.scope, + package: v.name, + version: v.version, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiMetrics { + pub packages: usize, + pub packages_1d: usize, + pub packages_7d: usize, + pub packages_30d: usize, + + pub users: usize, + pub users_1d: usize, + pub users_7d: usize, + pub users_30d: usize, + + pub package_versions: usize, + pub package_versions_1d: usize, + pub package_versions_7d: usize, + pub package_versions_30d: usize, +} diff --git a/crates/jsr_types/src/lib.rs b/crates/jsr_types/src/lib.rs index e1579b452..351ef9818 100644 --- a/crates/jsr_types/src/lib.rs +++ b/crates/jsr_types/src/lib.rs @@ -11,5 +11,6 @@ //! too, but are gated behind the default-off `sqlx` feature so wasm builds //! never compile them. +pub mod api; pub mod ids; pub mod models; diff --git a/workers-rs/Cargo.lock b/workers-rs/Cargo.lock index 62cb6bf10..0597e7324 100644 --- a/workers-rs/Cargo.lock +++ b/workers-rs/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -25,6 +34,51 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -64,6 +118,38 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "capacity_builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2d24a6dcf0cd402a21b65d35340f3a49ff3475dc5fdac91d22d2733e6641c6" +dependencies = [ + "capacity_builder_macros", + "ecow", + "hipstr", + "itoa", +] + +[[package]] +name = "capacity_builder_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -87,9 +173,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", + "windows-link", ] [[package]] @@ -104,6 +193,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -131,6 +226,44 @@ dependencies = [ "cmov", ] +[[package]] +name = "deno_error" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3007d3f1ea92ea503324ae15883aac0c2de2b8cf6fead62203ff6a67161007ab" +dependencies = [ + "deno_error_macro", + "libc", +] + +[[package]] +name = "deno_error_macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b565e60a9685cdf312c888665b5f8647ac692a7da7e058a5e2268a466da8eaf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deno_semver" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d46d2fd6959170a6e9f6607a6f79683868fa82ceac56ca520ab014e4fa5b21" +dependencies = [ + "capacity_builder", + "deno_error", + "ecow", + "hipstr", + "monch", + "once_cell", + "serde", + "thiserror", + "url", +] + [[package]] name = "digest" version = "0.11.3" @@ -154,6 +287,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ecow" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78e4f79b296fbaab6ce2e22d52cb4c7f010fe0ebe7a32e34fa25885fd797bd02" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -166,6 +308,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" @@ -279,6 +427,17 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hipstr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97971ffc85d4c98de12e2608e992a43f5294ebb625fdb045b27c731b64c4c6d6" +dependencies = [ + "serde", + "serde_bytes", + "sptr", +] + [[package]] name = "hmac" version = "0.13.0" @@ -308,6 +467,19 @@ dependencies = [ "http", ] +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "hybrid-array" version = "0.4.12" @@ -317,6 +489,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -460,14 +656,30 @@ dependencies = [ name = "jsr_api_worker" version = "0.1.0" dependencies = [ + "axum", "getrandom", + "jsr_types", "serde", "serde_json", "tokio-postgres", + "tower-service", "wasm-bindgen-futures", "worker", ] +[[package]] +name = "jsr_types" +version = "0.1.0" +dependencies = [ + "chrono", + "deno_semver", + "indexmap", + "serde", + "serde_json", + "thiserror", + "uuid", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -516,6 +728,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.11.0" @@ -532,6 +750,18 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "monch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52c1b33ff98142aecea13138bd399b68aa7ab5d9546c300988c345004001eea" + [[package]] name = "num-traits" version = "0.2.19" @@ -782,6 +1012,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -815,6 +1055,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -838,6 +1089,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "siphasher" version = "1.0.3" @@ -866,6 +1123,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -915,6 +1178,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -926,6 +1195,26 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1000,6 +1289,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "typenum" version = "1.20.1" @@ -1057,6 +1372,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "wasi" version = "0.14.7+wasi-0.2.4" @@ -1218,12 +1544,65 @@ dependencies = [ "web-sys", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1334,6 +1713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" dependencies = [ "async-trait", + "axum", "bytes", "chrono", "futures-channel", @@ -1341,7 +1721,7 @@ dependencies = [ "http", "http-body", "js-sys", - "matchit", + "matchit 0.7.3", "pin-project", "serde", "serde-wasm-bindgen", diff --git a/workers-rs/Cargo.toml b/workers-rs/Cargo.toml index acefe42c5..9623aa147 100644 --- a/workers-rs/Cargo.toml +++ b/workers-rs/Cargo.toml @@ -16,11 +16,23 @@ publish = false crate-type = ["cdylib"] [dependencies] -# The Cloudflare workers-rs runtime. Targets `wasm32-unknown-unknown`. -worker = "0.8" +# The Cloudflare workers-rs runtime. Targets `wasm32-unknown-unknown`. The +# `http`/`axum` features let us route with axum (over `http` crate types), to +# match the compute service's router-based structure. +worker = { version = "0.8", features = ["http", "axum"] } +# axum as the router. `default-features = false` drops the tokio/hyper server +# features (not wasm-compatible); `json` keeps `Json` responses. +axum = { version = "0.8", default-features = false, features = ["json"] } +tower-service = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Shared, wasm-safe JSR types (id newtypes, model structs, API wire types). +# `default-features = false` keeps the `sqlx` feature off so this stays +# wasm-compatible; the Worker only needs the plain structs to build +# byte-identical JSON responses (parity with the compute service). +jsr_types = { path = "../crates/jsr_types", default-features = false } + # Postgres access from the Worker. We reach the existing Cloud SQL Postgres # through Cloudflare Hyperdrive: `env.hyperdrive(..).connect()` hands back a # `worker::Socket` (a TCP stream to the Hyperdrive endpoint, TLS terminated by diff --git a/workers-rs/README.md b/workers-rs/README.md index 3e481587a..066bfc4d0 100644 --- a/workers-rs/README.md +++ b/workers-rs/README.md @@ -15,14 +15,26 @@ the migration sequence completes, this Worker will: ## Status -**Step 2 of the migration: scaffold only.** This crate currently exposes a -`GET /health` check and returns `501 Not Implemented` for everything else. No -database and no real endpoints are wired up yet; those land one endpoint group -per PR per the design doc's sequence. +**Step 4 of the migration: first read-only metadata GETs.** This crate exposes: + +- `GET /health` — liveness check. +- `GET /api/db_health` — Hyperdrive connectivity check (`SELECT 1`). +- `GET /api/stats` — front-page newest/updated/featured package lists. +- `GET /api/metrics` — registry-wide package/version/user counts. + +Everything else still returns `501 Not Implemented`; the remaining endpoint +groups land one PR at a time per the design doc's sequence. The Worker is not +yet fronting prod traffic. + +The `/api/stats` and `/api/metrics` handlers reach Postgres through Hyperdrive +(`tokio-postgres`, no `sqlx`) and serialize the **same** `jsr_types::api` wire +structs the compute service uses, so the JSON is byte-identical by construction. ## Layout -- `src/lib.rs` — the Worker entrypoint (`#[event(fetch)]`) and route table. +- `src/lib.rs` — the Worker entrypoint (`#[event(fetch)]`) and the `axum` router + (via the workers-rs `http` feature), matching the compute service's + router-based structure. - `Cargo.toml` — a **detached** workspace (its own `[workspace]` + `Cargo.lock`) so the repo-root `registry_api` native build never tries to compile this `wasm32`-only crate. diff --git a/workers-rs/src/db.rs b/workers-rs/src/db.rs index aae172749..b7281d9db 100644 --- a/workers-rs/src/db.rs +++ b/workers-rs/src/db.rs @@ -2,12 +2,32 @@ use std::str::FromStr; +use jsr_types::api::ApiMetrics; +use jsr_types::api::ApiStats; +use jsr_types::api::ApiStatsPackage; +use jsr_types::api::ApiStatsPackageVersion; +use jsr_types::ids::PackageName; +use jsr_types::ids::ScopeName; +use jsr_types::ids::Version; use tokio_postgres::Client; use tokio_postgres::NoTls; +use tokio_postgres::Row; use worker::Env; use worker::Error; use worker::Result; +fn map_err(e: E) -> Error { + Error::RustError(format!("postgres query failed: {e}")) +} + +fn scope_name(row: &Row, idx: &str) -> Result { + ScopeName::try_from(row.get::<_, String>(idx)).map_err(map_err) +} + +fn package_name(row: &Row, idx: &str) -> Result { + PackageName::try_from(row.get::<_, String>(idx)).map_err(map_err) +} + // Opens a Postgres connection through the Hyperdrive binding. Hyperdrive // terminates TLS to the origin, so the Worker→Hyperdrive hop is plaintext // (NoTls); tokio-postgres runs over the worker::Socket via connect_raw. @@ -37,3 +57,151 @@ pub async fn ping(client: &Client) -> Result { .map_err(|e| Error::RustError(format!("postgres query failed: {e}")))?; Ok(row.get::<_, i32>(0)) } + +/// `GET /api/stats`. Queries kept verbatim with `Database::package_stats`. +pub async fn stats(client: &Client) -> Result { + let newest_rows = client + .query( + r#"SELECT packages.scope as "scope", packages.name as "name" + FROM packages + WHERE EXISTS ( + SELECT 1 FROM package_versions + WHERE scope = packages.scope AND name = packages.name AND is_yanked = false + ) AND NOT packages.is_archived + ORDER BY packages.created_at DESC + LIMIT 10"#, + &[], + ) + .await + .map_err(map_err)?; + + let updated_rows = client + .query( + r#"SELECT package_versions.scope as "scope", package_versions.name as "name", package_versions.version as "version" + FROM package_versions + JOIN packages ON packages.scope = package_versions.scope AND packages.name = package_versions.name + WHERE NOT packages.is_archived + ORDER BY package_versions.created_at DESC + LIMIT 10"#, + &[], + ) + .await + .map_err(map_err)?; + + let featured_rows = client + .query( + r#"SELECT packages.scope as "scope", packages.name as "name" + FROM packages + WHERE packages.when_featured IS NOT NULL AND NOT packages.is_archived + ORDER BY packages.when_featured DESC + LIMIT 10"#, + &[], + ) + .await + .map_err(map_err)?; + + let mut newest = Vec::with_capacity(newest_rows.len()); + for row in &newest_rows { + newest.push(ApiStatsPackage { + scope: scope_name(row, "scope")?, + name: package_name(row, "name")?, + }); + } + + let mut updated = Vec::with_capacity(updated_rows.len()); + for row in &updated_rows { + updated.push(ApiStatsPackageVersion { + scope: scope_name(row, "scope")?, + package: package_name(row, "name")?, + version: Version::try_from(row.get::<_, String>("version").as_str()) + .map_err(map_err)?, + }); + } + + let mut featured = Vec::with_capacity(featured_rows.len()); + for row in &featured_rows { + featured.push(ApiStatsPackage { + scope: scope_name(row, "scope")?, + name: package_name(row, "name")?, + }); + } + + Ok(ApiStats { + newest, + updated, + featured, + }) +} + +/// `GET /api/metrics`. Queries kept verbatim with `Database::metrics`. +pub async fn metrics(client: &Client) -> Result { + let packages = client + .query_one( + r#" + SELECT + COUNT(DISTINCT (packages.name, packages.scope)) AS count_total, + COUNT(DISTINCT CASE WHEN package_versions.created_at >= NOW() - INTERVAL '1 day' THEN (packages.name, packages.scope) END) AS count_1d, + COUNT(DISTINCT CASE WHEN package_versions.created_at >= NOW() - INTERVAL '7 day' THEN (packages.name, packages.scope) END) AS count_7d, + COUNT(DISTINCT CASE WHEN package_versions.created_at >= NOW() - INTERVAL '30 day' THEN (packages.name, packages.scope) END) AS count_30d + FROM packages + LEFT JOIN + package_versions ON packages.name = package_versions.name AND packages.scope = package_versions.scope + WHERE + package_versions.name IS NOT NULL + "#, + &[], + ) + .await + .map_err(map_err)?; + + let users = client + .query_one( + r#" + SELECT + COUNT(*) AS count_total, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '1 DAY' THEN 1 END) AS count_1d, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '7 DAY' THEN 1 END) AS count_7d, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '30 DAY' THEN 1 END) AS count_30d + FROM users + "#, + &[], + ) + .await + .map_err(map_err)?; + + let package_versions = client + .query_one( + r#" + SELECT + COUNT(*) AS count_total, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '1 DAY' THEN 1 END) AS count_1d, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '7 DAY' THEN 1 END) AS count_7d, + COUNT(CASE WHEN created_at >= NOW() - INTERVAL '30 DAY' THEN 1 END) AS count_30d + FROM package_versions + "#, + &[], + ) + .await + .map_err(map_err)?; + + let count = |row: &Row, col: &str| -> Result { + usize::try_from(row.get::<_, i64>(col)).map_err(map_err) + }; + + Ok(ApiMetrics { + packages: count(&packages, "count_total")?, + packages_1d: count(&packages, "count_1d")?, + packages_7d: count(&packages, "count_7d")?, + packages_30d: count(&packages, "count_30d")?, + + users: count(&users, "count_total")?, + users_1d: count(&users, "count_1d")?, + users_7d: count(&users, "count_7d")?, + users_30d: count(&users, "count_30d")?, + + package_versions: count(&package_versions, "count_total")?, + package_versions_1d: count(&package_versions, "count_1d")?, + package_versions_7d: count(&package_versions, "count_7d")?, + package_versions_30d: count(&package_versions, "count_30d")?, + }) +} diff --git a/workers-rs/src/lib.rs b/workers-rs/src/lib.rs index 2471a80cd..dd637ff1c 100644 --- a/workers-rs/src/lib.rs +++ b/workers-rs/src/lib.rs @@ -3,41 +3,92 @@ //! The `api.jsr.io` Cloudflare Worker (workers-rs). Serves the lightweight //! CRUD/DB/auth surface and proxies heavy/native paths to the Cloud Run compute //! service. See `docs/design/api-service-split.md`. +//! +//! Routing uses `axum` (via the workers-rs `http` feature) to stay consistent +//! with the compute service's router-based structure. mod db; +use axum::Json; +use axum::Router; +use axum::extract::Extension; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::get; +use jsr_types::api::ApiMetrics; +use jsr_types::api::ApiStats; +use serde_json::Value; +use serde_json::json; +use tower_service::Service; +use worker::send::SendWrapper; use worker::*; #[event(fetch)] -async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { - router().run(req, env).await +async fn fetch( + req: HttpRequest, + env: Env, + _ctx: Context, +) -> Result> { + Ok(router(env).call(req).await?) } -fn router() -> Router<'static, ()> { +fn router(env: Env) -> Router { Router::new() - .get("/health", |_req, _ctx| health()) - .get_async("/api/db_health", |_req, ctx| async move { - db_health(&ctx.env).await - }) + .route("/health", get(health)) + .route("/api/db_health", get(db_health)) + .route("/api/stats", get(stats)) + .route("/api/metrics", get(metrics)) // Not-yet-migrated paths are explicitly unimplemented rather than 404. - .or_else_any_method("/*catchall", |_req, _ctx| { - Response::error("Not Implemented", 501) - }) + .fallback(|| async { (StatusCode::NOT_IMPLEMENTED, "Not Implemented") }) + .layer(Extension(SendWrapper::new(env))) } -fn health() -> Result { - Response::from_json(&serde_json::json!({ - "service": "jsr-api-worker", - "status": "ok", - })) +// Worker handlers hold the (`!Send`) Postgres client across awaits; +// `#[worker::send]` marks the resulting futures `Send` so axum accepts them. + +async fn health() -> Json { + Json(json!({ "service": "jsr-api-worker", "status": "ok" })) } -async fn db_health(env: &Env) -> Result { - let client = db::connect(env).await?; +#[worker::send] +async fn db_health( + Extension(env): Extension>, +) -> Result, AppError> { + let client = db::connect(&env).await?; let value = db::ping(&client).await?; - Response::from_json(&serde_json::json!({ - "service": "jsr-api-worker", - "database": "ok", - "select_1": value, - })) + Ok(Json( + json!({ "service": "jsr-api-worker", "database": "ok", "select_1": value }), + )) +} + +#[worker::send] +async fn stats( + Extension(env): Extension>, +) -> Result, AppError> { + let client = db::connect(&env).await?; + Ok(Json(db::stats(&client).await?)) +} + +#[worker::send] +async fn metrics( + Extension(env): Extension>, +) -> Result, AppError> { + let client = db::connect(&env).await?; + Ok(Json(db::metrics(&client).await?)) +} + +// Wraps a `worker::Error` as a 500 response (the error is logged, not exposed). +struct AppError(Error); + +impl From for AppError { + fn from(err: Error) -> Self { + Self(err) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + console_error!("request failed: {}", self.0); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() + } }