server/repository/znippy-nuget/src/registration.rs
//! **The two V3 resources holger's NuGet backend advertised nothing for** —
//! `RegistrationsBaseUrl` and `SearchQueryService` — synthesized from the
//! `.nuspec` inside each `.nupkg`.
//!
//! ## What was broken, precisely
//!
//! The flat container (`PackageBaseAddress`) answers exactly one question:
//! "give me `Thing` at exactly `1.2.3`". Every other question a real project
//! asks — resolve `Thing >= 1.2.0`, resolve `Thing`'s own dependencies,
//! find `Thing` by name — goes to the registration and search resources. With
//! neither advertised, a `dotnet restore` of any project that does not pin
//! every single package to an exact version fails. That is the whole of the
//! ?23 finding, and this module is the answer to it.
//!
//! ## The documents
//!
//! Per the NuGet V3 API (registration + search):
//!
//! * `…/v3/registration/{id}/index.json` — a `catalog:CatalogRoot` holding
//! `catalog:CatalogPage`s. Up to [`PAGE_SIZE`] versions the single page is
//! **inlined** (`items` present), which is the shape the spec recommends for
//! small registrations and which costs a client one request instead of two.
//! Past it the pages are linked and served separately, so the index of a
//! package with thousands of versions stays small.
//! * `…/v3/registration/{id}/page/{lower}/{upper}.json` — one linked page.
//! * `…/v3/registration/{id}/{version}.json` — one leaf, for the clients that
//! follow a leaf `@id` rather than reading the inlined `catalogEntry`.
//! * `…/v3/search?q=&skip=&take=&prerelease=` — the search response, whose
//! `data[]` entries carry `versions[]` so a client can see the whole span
//! without a second round trip.
//!
//! ## Ordering is NOT string ordering
//!
//! `lower`, `upper` and "which version is latest" are all version-ordered, and
//! a lexicographic sort gets that wrong in two ways that matter: `10.0.0`
//! sorts before `9.0.0`, and `1.0.0-beta` sorts *after* `1.0.0` when SemVer
//! says a prerelease precedes its release. [`compare_versions`] implements
//! NuGetVersion ordering — four numeric fields, then SemVer prerelease rules,
//! build metadata ignored.
//!
//! ## Cost
//!
//! Every document here is built from an already-parsed
//! [`PackageVersion`] slice and touches no archive bytes. The expensive half —
//! opening each `.nupkg` for its `.nuspec` — happens ONCE, in `lib.rs`'s
//! memoized catalog.
use std::cmp::Ordering;
use serde_json::{json, Value};
use crate::nuspec::Nuspec;
/// Versions per registration page before the pages stop being inlined.
///
/// Inlining is one request instead of two and is what the spec recommends for
/// a small registration. 64 keeps the inlined index in the tens of kilobytes
/// for a typical package while the overwhelming majority of real packages
/// (which have far fewer than 64 published versions) never pay for a second
/// request at all.
pub const PAGE_SIZE: usize = 64;
/// A fixed commit stamp.
///
/// The registration format carries `commitId`/`commitTimeStamp` so a client can
/// tell one snapshot of nuget.org from the next. holger serves a **read-only
/// znippy archive**: the content cannot change under the process, so there is
/// no sequence of commits to report and inventing a fresh timestamp per request
/// would make every response differ from the last for no reason — defeating
/// client-side caching. A constant is the honest answer, and it is the same
/// answer the archive's own immutability gives.
const COMMIT_TS: &str = "2000-01-01T00:00:00+00:00";
/// One published version, as the catalog knows it.
#[derive(Debug, Clone)]
pub struct PackageVersion {
/// The parsed `.nuspec`.
pub nuspec: Nuspec,
/// The version segment as it appears in the ARCHIVE path.
///
/// Not necessarily the `.nuspec`'s own spelling of the version, and it is
/// the archive's spelling that `packageContent` must be built from — a
/// `packageContent` URL that 404s is worse than no registration at all,
/// because the client gets all the way to the download before failing.
pub path_version: String,
/// The `.nupkg` filename inside the archive.
pub file: String,
}
impl PackageVersion {
/// The version a client should see: the `.nuspec`'s, which is the
/// authoritative spelling, falling back to the path's.
pub fn version(&self) -> &str {
if self.nuspec.version.is_empty() {
&self.path_version
} else {
&self.nuspec.version
}
}
/// Whether this is a prerelease — i.e. whether it is hidden from a default
/// `dotnet add package` and from a search without `prerelease=true`.
pub fn is_prerelease(&self) -> bool {
is_prerelease(self.version())
}
}
/// Whether a version string carries a SemVer prerelease label.
pub fn is_prerelease(v: &str) -> bool {
let core = v.split('+').next().unwrap_or(v);
core.contains('-')
}
/// Split a version into its numeric fields and its prerelease labels,
/// discarding build metadata.
fn split_version(v: &str) -> ([u64; 4], Vec<String>) {
let core = v.split('+').next().unwrap_or(v);
let (nums, pre) = match core.split_once('-') {
Some((n, p)) => (n, p),
None => (core, ""),
};
let mut fields = [0u64; 4];
for (i, part) in nums.split('.').take(4).enumerate() {
fields[i] = part.trim().parse::<u64>().unwrap_or(0);
}
let labels = if pre.is_empty() {
Vec::new()
} else {
pre.split('.').map(|s| s.to_string()).collect()
};
(fields, labels)
}
/// NuGetVersion ordering.
///
/// Numeric fields first (missing fields are zero, so `1.0` == `1.0.0`), then
/// the SemVer prerelease rules: **absence of a prerelease sorts HIGHER**, a
/// numeric label sorts below an alphanumeric one, numeric labels compare as
/// numbers and alphanumeric ones case-insensitively (NuGet, unlike strict
/// SemVer, is case-insensitive here), and a shorter label list sorts lower when
/// all shared labels are equal. Build metadata is ignored, per SemVer.
pub fn compare_versions(a: &str, b: &str) -> Ordering {
let (an, ap) = split_version(a);
let (bn, bp) = split_version(b);
match an.cmp(&bn) {
Ordering::Equal => {}
o => return o,
}
match (ap.is_empty(), bp.is_empty()) {
(true, true) => return Ordering::Equal,
// A release outranks its own prereleases.
(true, false) => return Ordering::Greater,
(false, true) => return Ordering::Less,
(false, false) => {}
}
for (x, y) in ap.iter().zip(bp.iter()) {
let xn = x.parse::<u64>().ok();
let yn = y.parse::<u64>().ok();
let o = match (xn, yn) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => x.to_ascii_lowercase().cmp(&y.to_ascii_lowercase()),
};
if o != Ordering::Equal {
return o;
}
}
ap.len().cmp(&bp.len())
}
/// Sort in place, ascending, by [`compare_versions`].
pub fn sort_versions(v: &mut [PackageVersion]) {
v.sort_by(|a, b| compare_versions(a.version(), b.version()));
}
// ── URLs ─────────────────────────────────────────────────────────────────────
fn reg_base(base: &str, repo: &str) -> String {
format!("{base}/{repo}/v3/registration")
}
fn index_url(base: &str, repo: &str, id_lower: &str) -> String {
format!("{}/{id_lower}/index.json", reg_base(base, repo))
}
fn page_url(base: &str, repo: &str, id_lower: &str, lower: &str, upper: &str) -> String {
format!(
"{}/{id_lower}/page/{lower}/{upper}.json",
reg_base(base, repo)
)
}
fn leaf_url(base: &str, repo: &str, id_lower: &str, version: &str) -> String {
format!(
"{}/{id_lower}/{}.json",
reg_base(base, repo),
version.to_lowercase()
)
}
/// The flat-container URL for the actual bytes. Built from the ARCHIVE's own
/// path segments, so it always resolves against the same backend's
/// `v3-flatcontainer` arm.
fn content_url(base: &str, repo: &str, id_lower: &str, pv: &PackageVersion) -> String {
format!(
"{base}/{repo}/v3-flatcontainer/{id_lower}/{}/{}",
pv.path_version, pv.file
)
}
// ── Documents ────────────────────────────────────────────────────────────────
fn dependency_groups_json(base: &str, repo: &str, n: &Nuspec) -> Value {
Value::Array(
n.dependency_groups
.iter()
.map(|g| {
let deps: Vec<Value> = g
.dependencies
.iter()
.map(|d| {
json!({
"@type": "PackageDependency",
"id": d.id,
"range": d.range,
// The registration of the DEPENDENCY, so a client
// resolving a graph walks straight on without
// going back to the service index.
"registration": index_url(base, repo, &d.id.to_lowercase()),
})
})
.collect();
let mut g_json = json!({
"@type": "PackageDependencyGroup",
"dependencies": deps,
});
if let Some(tfm) = &g.target_framework {
g_json["targetFramework"] = json!(tfm);
}
g_json
})
.collect(),
)
}
/// One `catalogEntry` — the `PackageDetails` a client reads a version's facts
/// out of.
fn catalog_entry(base: &str, repo: &str, id_lower: &str, pv: &PackageVersion) -> Value {
let n = &pv.nuspec;
let version = pv.version();
let mut e = json!({
"@id": leaf_url(base, repo, id_lower, version),
"@type": "PackageDetails",
"id": n.id,
"version": version,
"dependencyGroups": dependency_groups_json(base, repo, n),
// `listed: false` HIDES a version from restore. Everything in the
// archive is there to be served, so everything is listed.
"listed": true,
"packageContent": content_url(base, repo, id_lower, pv),
// See COMMIT_TS: a read-only archive has no publication history, and a
// fabricated date would be a fact holger does not have. The V3 sentinel
// for "unknown" is the year-1900 stamp, which clients already handle.
"published": "1900-01-01T00:00:00+00:00",
"requireLicenseAcceptance": n.require_license_acceptance,
});
if let Some(v) = &n.authors {
e["authors"] = json!(v);
}
if let Some(v) = &n.description {
e["description"] = json!(v);
}
if let Some(v) = &n.summary {
e["summary"] = json!(v);
}
if let Some(v) = &n.title {
e["title"] = json!(v);
}
if let Some(v) = &n.tags {
e["tags"] = json!(v
.split(&[' ', ',', ';'][..])
.filter(|s| !s.is_empty())
.collect::<Vec<_>>());
}
if let Some(v) = &n.project_url {
e["projectUrl"] = json!(v);
}
if let Some(v) = &n.license_url {
e["licenseUrl"] = json!(v);
}
if let Some(v) = &n.license_expression {
e["licenseExpression"] = json!(v);
}
e
}
fn leaf(base: &str, repo: &str, id_lower: &str, pv: &PackageVersion) -> Value {
json!({
"@id": leaf_url(base, repo, id_lower, pv.version()),
"@type": ["Package", "catalog:Permalink"],
"commitId": COMMIT_TS,
"commitTimeStamp": COMMIT_TS,
"catalogEntry": catalog_entry(base, repo, id_lower, pv),
"packageContent": content_url(base, repo, id_lower, pv),
"registration": index_url(base, repo, id_lower),
})
}
/// One page object. `inline` decides whether its `items` are carried here (the
/// small-registration shape) or left to a separate document.
fn page(base: &str, repo: &str, id_lower: &str, chunk: &[PackageVersion], inline: bool) -> Value {
let lower = chunk.first().map(|p| p.version()).unwrap_or("0.0.0");
let upper = chunk.last().map(|p| p.version()).unwrap_or("0.0.0");
let mut p = json!({
"@id": page_url(base, repo, id_lower, lower, upper),
"@type": "catalog:CatalogPage",
"commitId": COMMIT_TS,
"commitTimeStamp": COMMIT_TS,
"count": chunk.len(),
"lower": lower,
"parent": index_url(base, repo, id_lower),
"upper": upper,
});
if inline {
p["items"] = Value::Array(
chunk
.iter()
.map(|pv| leaf(base, repo, id_lower, pv))
.collect(),
);
}
p
}
/// `…/v3/registration/{id}/index.json`. `versions` must be sorted ascending.
pub fn registration_index(
base: &str,
repo: &str,
id_lower: &str,
versions: &[PackageVersion],
) -> Vec<u8> {
let inline = versions.len() <= PAGE_SIZE;
let pages: Vec<Value> = versions
.chunks(PAGE_SIZE.max(1))
.map(|c| page(base, repo, id_lower, c, inline))
.collect();
let doc = json!({
"@id": index_url(base, repo, id_lower),
"@type": ["catalog:CatalogRoot", "PackageRegistration", "catalog:Permalink"],
"commitId": COMMIT_TS,
"commitTimeStamp": COMMIT_TS,
"count": pages.len(),
"items": pages,
});
serde_json::to_vec(&doc).unwrap_or_else(|_| b"{}".to_vec())
}
/// One linked page document, selected by its `lower`/`upper` bounds. `None`
/// when no page has those bounds — which is a 404, not an empty page.
pub fn registration_page(
base: &str,
repo: &str,
id_lower: &str,
versions: &[PackageVersion],
lower: &str,
upper: &str,
) -> Option<Vec<u8>> {
let chunk = versions.chunks(PAGE_SIZE.max(1)).find(|c| {
c.first().map(|p| p.version()) == Some(lower)
&& c.last().map(|p| p.version()) == Some(upper)
})?;
let mut p = page(base, repo, id_lower, chunk, true);
// A standalone page document is its own root; the inlined form is not.
p["@type"] = json!("catalog:CatalogPage");
Some(serde_json::to_vec(&p).unwrap_or_else(|_| b"{}".to_vec()))
}
/// One leaf document, `…/{id}/{version}.json`.
pub fn registration_leaf(
base: &str,
repo: &str,
id_lower: &str,
versions: &[PackageVersion],
version: &str,
) -> Option<Vec<u8>> {
let pv = versions
.iter()
.find(|p| p.version().eq_ignore_ascii_case(version))?;
Some(serde_json::to_vec(&leaf(base, repo, id_lower, pv)).unwrap_or_else(|_| b"{}".to_vec()))
}
/// One search hit: the package, at its newest matching version, with the whole
/// version span carried inline.
fn search_hit(
base: &str,
repo: &str,
id_lower: &str,
versions: &[PackageVersion],
prerelease: bool,
) -> Option<Value> {
let visible: Vec<&PackageVersion> = versions
.iter()
.filter(|p| prerelease || !p.is_prerelease())
.collect();
let newest = visible.last()?;
let n = &newest.nuspec;
let mut hit = json!({
"@id": index_url(base, repo, id_lower),
"@type": "Package",
"registration": index_url(base, repo, id_lower),
"id": n.id,
"version": newest.version(),
"versions": visible.iter().map(|p| json!({
"@id": leaf_url(base, repo, id_lower, p.version()),
"version": p.version(),
// holger counts no downloads and will not invent a number.
"downloads": 0,
})).collect::<Vec<_>>(),
"totalDownloads": 0,
"verified": false,
});
if let Some(v) = &n.title {
hit["title"] = json!(v);
}
if let Some(v) = &n.description {
hit["description"] = json!(v);
}
if let Some(v) = &n.summary {
hit["summary"] = json!(v);
}
if let Some(v) = &n.authors {
hit["authors"] = json!(v
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>());
}
if let Some(v) = &n.tags {
hit["tags"] = json!(v
.split(&[' ', ',', ';'][..])
.filter(|s| !s.is_empty())
.collect::<Vec<_>>());
}
if let Some(v) = &n.project_url {
hit["projectUrl"] = json!(v);
}
if let Some(v) = &n.license_url {
hit["licenseUrl"] = json!(v);
}
Some(hit)
}
/// The `SearchQueryService` response.
///
/// `q` matches case-insensitively against the package id, its title and its
/// tags — the three fields `dotnet package search` actually shows. An empty
/// `q` lists everything, which is what a client's "browse" does.
pub fn search_response<'a>(
base: &str,
repo: &str,
catalog: impl Iterator<Item = (&'a str, &'a Vec<PackageVersion>)>,
q: &str,
skip: usize,
take: usize,
prerelease: bool,
) -> Vec<u8> {
let needle = q.trim().to_lowercase();
let mut hits: Vec<(String, Value)> = Vec::new();
for (id_lower, versions) in catalog {
if versions.is_empty() {
continue;
}
if !needle.is_empty() {
let n = &versions[versions.len() - 1].nuspec;
let hay = format!(
"{} {} {}",
id_lower,
n.title.clone().unwrap_or_default().to_lowercase(),
n.tags.clone().unwrap_or_default().to_lowercase()
);
if !hay.contains(&needle) {
continue;
}
}
if let Some(h) = search_hit(base, repo, id_lower, versions, prerelease) {
hits.push((id_lower.to_string(), h));
}
}
// Stable, id-ordered: holger has no relevance signal (no downloads, no
// ranking), and a stable order is worth more to a client than a fake one.
hits.sort_by(|a, b| a.0.cmp(&b.0));
let total = hits.len();
let data: Vec<Value> = hits
.into_iter()
.skip(skip)
.take(take)
.map(|(_, v)| v)
.collect();
let doc = json!({
"@context": { "@vocab": "http://schema.nuget.org/schema#", "@base": reg_base(base, repo) },
"totalHits": total,
"data": data,
});
serde_json::to_vec(&doc).unwrap_or_else(|_| b"{\"totalHits\":0,\"data\":[]}".to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nuspec::{Dependency, DependencyGroup};
fn pv(id: &str, version: &str, deps: &[(&str, &str)]) -> PackageVersion {
PackageVersion {
nuspec: Nuspec {
id: id.to_string(),
version: version.to_string(),
title: Some(format!("{id} title")),
authors: Some("Vetra Automation AB".into()),
description: Some("a package".into()),
summary: None,
tags: Some("airgap holger".into()),
project_url: None,
license_url: None,
license_expression: Some("MIT".into()),
require_license_acceptance: false,
dependency_groups: if deps.is_empty() {
Vec::new()
} else {
vec![DependencyGroup {
target_framework: Some("net8.0".into()),
dependencies: deps
.iter()
.map(|(i, r)| Dependency {
id: (*i).to_string(),
range: (*r).to_string(),
})
.collect(),
}]
},
},
path_version: version.to_string(),
file: format!("{}.{version}.nupkg", id.to_lowercase()),
}
}
/// ★ The ordering a lexicographic sort gets wrong in both directions.
#[test]
fn versions_order_as_nuget_orders_them_not_as_strings() {
let mut v = vec![
"1.0.0".to_string(),
"10.0.0".into(),
"9.0.0".into(),
"1.0.0-beta".into(),
"1.0.0-alpha".into(),
"1.0.0-alpha.2".into(),
"1.0.0-alpha.10".into(),
"2.0".into(),
];
v.sort_by(|a, b| compare_versions(a, b));
assert_eq!(
v,
vec![
"1.0.0-alpha",
"1.0.0-alpha.2",
// .10 after .2: NUMERIC label comparison, not string.
"1.0.0-alpha.10",
"1.0.0-beta",
// The release outranks all of its prereleases.
"1.0.0",
"2.0",
// 9 before 10: numeric field comparison, not string.
"9.0.0",
"10.0.0",
]
);
// Missing fields are zero, so these are the same version.
assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Equal);
assert_eq!(
compare_versions("1.0.0+build1", "1.0.0+build2"),
Ordering::Equal
);
// NuGet is case-insensitive in prerelease labels; SemVer is not.
assert_eq!(
compare_versions("1.0.0-Alpha", "1.0.0-alpha"),
Ordering::Equal
);
}
/// ★ **The document `dotnet restore` needs and could not get.** A range
/// (`[1.0.0, )`) can only be resolved by reading a registration index, and
/// the dependency's own registration URL must be in it or the walk stops.
#[test]
fn the_registration_index_carries_ranges_and_the_next_registration() {
let versions = vec![
pv("Thing", "1.0.0", &[("Newtonsoft.Json", "[13.0.1, )")]),
pv("Thing", "1.1.0", &[]),
];
let body = registration_index("https://h.example", "nuget", "thing", &versions);
let doc: Value = serde_json::from_slice(&body).expect("valid JSON");
assert_eq!(
doc["@id"],
"https://h.example/nuget/v3/registration/thing/index.json"
);
assert_eq!(doc["count"], 1, "two versions fit one page");
let pagev = &doc["items"][0];
assert_eq!(pagev["lower"], "1.0.0");
assert_eq!(pagev["upper"], "1.1.0");
assert_eq!(pagev["count"], 2);
let leaf0 = &pagev["items"][0];
assert_eq!(leaf0["catalogEntry"]["version"], "1.0.0");
assert_eq!(leaf0["catalogEntry"]["listed"], true);
// packageContent must point at THIS backend's flat container, or the
// client resolves the graph and then 404s on the download.
assert_eq!(
leaf0["packageContent"],
"https://h.example/nuget/v3-flatcontainer/thing/1.0.0/thing.1.0.0.nupkg"
);
let group = &leaf0["catalogEntry"]["dependencyGroups"][0];
assert_eq!(group["targetFramework"], "net8.0");
assert_eq!(group["dependencies"][0]["id"], "Newtonsoft.Json");
assert_eq!(group["dependencies"][0]["range"], "[13.0.1, )");
assert_eq!(
group["dependencies"][0]["registration"],
"https://h.example/nuget/v3/registration/newtonsoft.json/index.json",
"the graph walk continues without going back to the service index"
);
}
/// Past PAGE_SIZE the index stops inlining and the pages become documents
/// of their own — so a package with thousands of versions does not make
/// every index request a multi-megabyte one.
#[test]
fn a_large_registration_links_its_pages_instead_of_inlining_them() {
let versions: Vec<PackageVersion> = (0..PAGE_SIZE * 2 + 5)
.map(|i| pv("Thing", &format!("1.0.{i}"), &[]))
.collect();
let body = registration_index("https://h.example", "nuget", "thing", &versions);
let doc: Value = serde_json::from_slice(&body).expect("valid JSON");
assert_eq!(doc["count"], 3, "three pages");
assert!(
doc["items"][0]["items"].is_null(),
"pages are LINKED, not inlined"
);
// …and each linked page is fetchable at the bounds the index names.
let lower = doc["items"][1]["lower"]
.as_str()
.expect("lower")
.to_string();
let upper = doc["items"][1]["upper"]
.as_str()
.expect("upper")
.to_string();
let page = registration_page(
"https://h.example",
"nuget",
"thing",
&versions,
&lower,
&upper,
)
.expect("the page the index pointed at exists");
let p: Value = serde_json::from_slice(&page).expect("valid JSON");
assert_eq!(p["count"], PAGE_SIZE);
assert_eq!(p["items"][0]["catalogEntry"]["version"], lower);
// A page nobody advertised is a 404, not an empty page.
assert!(registration_page(
"https://h.example",
"nuget",
"thing",
&versions,
"9.9.9",
"9.9.9"
)
.is_none());
}
#[test]
fn a_leaf_is_served_by_version_case_insensitively() {
let versions = vec![pv("Thing", "1.0.0-Beta", &[])];
let body = registration_leaf(
"https://h.example",
"nuget",
"thing",
&versions,
"1.0.0-beta",
)
.expect("found");
let doc: Value = serde_json::from_slice(&body).expect("valid JSON");
assert_eq!(doc["catalogEntry"]["version"], "1.0.0-Beta");
assert!(
registration_leaf("https://h.example", "nuget", "thing", &versions, "2.0.0").is_none()
);
}
/// ★ Search: the resource that lets a human find a package at all, and the
/// `prerelease` flag that decides whether a beta is a legal answer.
#[test]
fn search_filters_by_name_and_hides_prereleases_unless_asked() {
let thing = vec![pv("Thing", "1.0.0", &[]), pv("Thing", "2.0.0-rc.1", &[])];
let other = vec![pv("Other.Pkg", "1.0.0", &[])];
let catalog: Vec<(&str, &Vec<PackageVersion>)> =
vec![("thing", &thing), ("other.pkg", &other)];
let all = search_response(
"https://h.example",
"nuget",
catalog.iter().copied(),
"",
0,
20,
false,
);
let d: Value = serde_json::from_slice(&all).expect("valid JSON");
assert_eq!(d["totalHits"], 2);
// id-ordered, and the prerelease is NOT the reported version.
assert_eq!(d["data"][0]["id"], "Other.Pkg");
assert_eq!(d["data"][1]["id"], "Thing");
assert_eq!(
d["data"][1]["version"], "1.0.0",
"the rc is hidden by default"
);
assert_eq!(d["data"][1]["versions"].as_array().expect("array").len(), 1);
let pre = search_response(
"https://h.example",
"nuget",
catalog.iter().copied(),
"thing",
0,
20,
true,
);
let d: Value = serde_json::from_slice(&pre).expect("valid JSON");
assert_eq!(d["totalHits"], 1, "q filters");
assert_eq!(
d["data"][0]["version"], "2.0.0-rc.1",
"prerelease=true reveals the rc"
);
// skip/take are the paging a client walks a big result with.
let paged = search_response(
"https://h.example",
"nuget",
catalog.iter().copied(),
"",
1,
1,
false,
);
let d: Value = serde_json::from_slice(&paged).expect("valid JSON");
assert_eq!(d["totalHits"], 2, "totalHits is the UNPAGED count");
assert_eq!(d["data"].as_array().expect("array").len(), 1);
assert_eq!(d["data"][0]["id"], "Thing");
}
}