Skip to content

Commit fb38678

Browse files
authored
Fix up serialization and docs (#10)
1 parent adb3137 commit fb38678

File tree

5 files changed

+390
-259
lines changed

5 files changed

+390
-259
lines changed

Cargo.toml

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,22 @@ edition = "2021"
66
[dependencies]
77
alphanumeric-sort = "1.5.3"
88
getset = "0.1.2"
9-
lazy_static = "1.4.0"
109
pretty_assertions = "1.4.0"
11-
regex = "1.6.0"
1210
serde = { version = "1.0.140", features = ["derive"] }
1311
strum = { version = "0.24.1", features = ["derive"] }
1412
thiserror = "1.0.31"
1513
utoipa = "4.2.3"
1614
serde_json = "1.0.95"
17-
documented = "0.4.1"
15+
documented = "0.7.1"
1816
semver = "1.0.23"
1917
bon = "2.3.0"
2018
duplicate = "2.0.0"
19+
lazy-regex = { version = "3.3.0", features = ["regex"] }
2120

2221
[dev-dependencies]
2322
assert_matches = "1.5.0"
2423
impls = "1.0.3"
2524
itertools = "0.10.5"
2625
proptest = "1.0.0"
26+
simple_test_case = "1.2.0"
2727
static_assertions = "1.1.0"

src/lib.rs

+163-88
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
#![deny(missing_docs)]
44
#![warn(rust_2018_idioms)]
55

6-
use std::{borrow::Cow, str::FromStr};
6+
use std::{borrow::Cow, num::ParseIntError, str::FromStr};
77

8+
use documented::Documented;
89
use duplicate::duplicate;
9-
use lazy_static::lazy_static;
10-
use regex::Regex;
1110
use serde::{Deserialize, Serialize};
1211
use strum::{AsRefStr, Display, EnumIter, EnumString};
1312
use utoipa::ToSchema;
@@ -23,13 +22,7 @@ pub use locator::*;
2322
pub use locator_package::*;
2423
pub use locator_strict::*;
2524

26-
/// [`Locator`](crate::Locator) is closely tied with the concept of Core's "fetchers",
27-
/// which are asynchronous jobs tasked with downloading the code
28-
/// referred to by a [`Locator`](crate::Locator) so that Core or some other service
29-
/// may analyze it.
30-
///
31-
/// For more information on the background of `Locator` and fetchers generally,
32-
/// refer to [Fetchers and Locators](https://go/fetchers-doc).
25+
/// `Fetcher` identifies a supported code host protocol.
3326
#[derive(
3427
Copy,
3528
Clone,
@@ -45,10 +38,12 @@ pub use locator_strict::*;
4538
AsRefStr,
4639
Serialize,
4740
Deserialize,
41+
Documented,
4842
ToSchema,
4943
)]
5044
#[non_exhaustive]
5145
#[serde(rename_all = "snake_case")]
46+
#[schema(example = json!("git"))]
5247
pub enum Fetcher {
5348
/// Archive locators are FOSSA specific.
5449
#[strum(serialize = "archive")]
@@ -176,7 +171,13 @@ pub enum Fetcher {
176171
}
177172

178173
/// Identifies the organization to which this locator is namespaced.
179-
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
174+
///
175+
/// Organization IDs are canonically created by FOSSA instances
176+
/// and have no meaning outside of FOSSA instances.
177+
#[derive(
178+
Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Hash, Documented, ToSchema,
179+
)]
180+
#[schema(example = json!(1))]
180181
pub struct OrgId(usize);
181182

182183
impl From<OrgId> for usize {
@@ -191,6 +192,15 @@ impl From<usize> for OrgId {
191192
}
192193
}
193194

195+
impl FromStr for OrgId {
196+
type Err = ParseIntError;
197+
198+
fn from_str(s: &str) -> Result<Self, Self::Err> {
199+
let id = s.parse()?;
200+
Ok(Self(id))
201+
}
202+
}
203+
194204
duplicate! {
195205
[
196206
number;
@@ -237,7 +247,16 @@ impl std::fmt::Debug for OrgId {
237247
}
238248

239249
/// The package section of the locator.
240-
#[derive(Clone, Eq, PartialEq, Hash)]
250+
///
251+
/// A "package" is generally the name of a project or dependency in a code host.
252+
/// However some fetcher protocols (such as `git`) embed additional information
253+
/// inside the `Package` of a locator, such as the URL of the `git` instance
254+
/// from which the project can be fetched.
255+
///
256+
/// Additionally, some fetcher protocols (such as `apk`, `rpm-generic`, and `deb`)
257+
/// further encode additional standardized information in the `Package` of the locator.
258+
#[derive(Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Documented, ToSchema)]
259+
#[schema(example = json!("github.com/fossas/locator-rs"))]
241260
pub struct Package(String);
242261

243262
impl Package {
@@ -290,9 +309,15 @@ impl std::cmp::PartialOrd for Package {
290309
}
291310

292311
/// The revision section of the locator.
293-
#[derive(Clone, Eq, PartialEq, Hash)]
312+
///
313+
/// A "revision" is the version of the project in the code host.
314+
/// Some fetcher protocols (such as `apk`, `rpm-generic`, and `deb`)
315+
/// encode additional standardized information in the `Revision` of the locator.
316+
#[derive(Clone, Eq, PartialEq, Hash, Documented, ToSchema)]
317+
#[schema(example = json!("v1.0.0"))]
294318
pub enum Revision {
295319
/// The revision is valid semver.
320+
#[schema(value_type = String)]
296321
Semver(semver::Version),
297322

298323
/// The revision is an opaque string.
@@ -345,6 +370,24 @@ impl std::fmt::Debug for Revision {
345370
}
346371
}
347372

373+
impl Serialize for Revision {
374+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
375+
where
376+
S: serde::Serializer,
377+
{
378+
self.to_string().serialize(serializer)
379+
}
380+
}
381+
382+
impl<'de> Deserialize<'de> for Revision {
383+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
384+
where
385+
D: serde::Deserializer<'de>,
386+
{
387+
String::deserialize(deserializer).map(Self::from)
388+
}
389+
}
390+
348391
impl std::cmp::Ord for Revision {
349392
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
350393
let cmp = alphanumeric_sort::compare_str;
@@ -364,46 +407,39 @@ impl std::cmp::PartialOrd for Revision {
364407
}
365408

366409
/// Optionally parse an org ID and trimmed package out of a package string.
367-
fn parse_org_package(package: &str) -> Result<(Option<OrgId>, Package), PackageParseError> {
368-
lazy_static! {
369-
static ref RE: Regex = Regex::new(r"^(?:(?P<org_id>\d+)/)?(?P<package>.+)")
370-
.expect("Package parsing expression must compile");
371-
}
372-
373-
let mut captures = RE.captures_iter(package);
374-
let capture = captures.next().ok_or_else(|| PackageParseError::Package {
375-
package: package.to_string(),
376-
})?;
377-
378-
let trimmed_package =
379-
capture
380-
.name("package")
381-
.map(|m| m.as_str())
382-
.ok_or_else(|| PackageParseError::Field {
383-
package: package.to_string(),
384-
field: String::from("package"),
385-
})?;
386-
387-
// If we fail to parse the org_id as a valid number, don't fail the overall parse;
388-
// just don't namespace to org ID and return the input unmodified.
389-
match capture
390-
.name("org_id")
391-
.map(|m| m.as_str())
392-
.map(OrgId::try_from)
393-
{
394-
// An org ID was provided and validly parsed, use it.
395-
Some(Ok(org_id)) => Ok((Some(org_id), Package::from(trimmed_package))),
396-
397-
// Otherwise, if we either didn't get an org ID section,
398-
// or it wasn't a valid org ID,
399-
// just use the package as-is.
400-
_ => Ok((None, Package::from(package))),
410+
fn parse_org_package(input: &str) -> (Option<OrgId>, Package) {
411+
macro_rules! construct {
412+
($org_id:expr, $package:expr) => {
413+
return (Some($org_id), Package::from($package))
414+
};
415+
($package:expr) => {
416+
return (None, Package::from($package))
417+
};
401418
}
419+
420+
// No `/`? This must not be namespaced.
421+
let Some((org_id, package)) = input.split_once('/') else {
422+
construct!(input);
423+
};
424+
425+
// Nothing before or after the `/`? Still not namespaced.
426+
if org_id.is_empty() || package.is_empty() {
427+
construct!(input);
428+
};
429+
430+
// If the part before the `/` isn't a number, it can't be a namespaced org id.
431+
let Ok(org_id) = org_id.parse() else {
432+
construct!(input)
433+
};
434+
435+
// Ok, there was text before and after the `/`, and the content before was a number.
436+
// Finally, we've got a namespaced package.
437+
construct!(org_id, package)
402438
}
403439

404440
#[cfg(test)]
405441
mod tests {
406-
use itertools::izip;
442+
use simple_test_case::test_case;
407443

408444
use super::*;
409445

@@ -413,52 +449,91 @@ mod tests {
413449
}
414450
}
415451

452+
macro_rules! revision {
453+
(semver => $input:expr) => {
454+
Revision::Semver(semver::Version::parse($input).expect("parse semver"))
455+
};
456+
(opaque => $input:expr) => {
457+
Revision::Opaque(String::from($input))
458+
};
459+
}
460+
461+
#[test_case("0/name", Some(OrgId(0)), Package::new("name"); "0/name")]
462+
#[test_case("1/name", Some(OrgId(1)), Package::new("name"); "1/name")]
463+
#[test_case("1/name/foo", Some(OrgId(1)), Package::new("name/foo"); "1/name/foo")]
464+
#[test_case("1//name/foo", Some(OrgId(1)), Package::new("/name/foo"); "doubleslash_1/name/foo")]
465+
#[test_case("9809572/name/foo", Some(OrgId(9809572)), Package::new("name/foo"); "9809572/name/foo")]
466+
#[test_case("name/foo", None, Package::new("name/foo"); "name/foo")]
467+
#[test_case("name", None, Package::new("name"); "name")]
468+
#[test_case("/name/foo", None, Package::new("/name/foo"); "/name/foo")]
469+
#[test_case("/123/name/foo", None, Package::new("/123/name/foo"); "/123/name/foo")]
470+
#[test_case("/name", None, Package::new("/name"); "/name")]
471+
#[test_case("abcd/1234/name", None, Package::new("abcd/1234/name"); "abcd/1234/name")]
472+
#[test_case("1abc2/name", None, Package::new("1abc2/name"); "1abc2/name")]
473+
#[test_case("name/1234", None, Package::new("name/1234"); "name/1234")]
416474
#[test]
417-
fn parses_org_package() {
418-
let orgs = [OrgId(0usize), OrgId(1), OrgId(9809572)];
419-
let names = [Package::new("name"), Package::new("name/foo")];
420-
421-
for (org, name) in izip!(orgs, names) {
422-
let test = format!("{org}/{name}");
423-
let Ok((Some(org_id), package)) = parse_org_package(&test) else {
424-
panic!("must parse '{test}'")
425-
};
426-
assert_eq!(org_id, org, "'org_id' must match in '{test}'");
427-
assert_eq!(package, name, "'package' must match in '{test}");
428-
}
475+
fn parse_org_package(input: &str, org: Option<OrgId>, package: Package) {
476+
let (org_id, name) = parse_org_package(input);
477+
assert_eq!(org_id, org, "'org_id' must match in '{input}'");
478+
assert_eq!(package, name, "'package' must match in '{input}");
429479
}
430480

481+
#[test_case(r#""rpm-generic""#, Fetcher::LinuxRpm; "rpm-generic")]
482+
#[test_case(r#""deb""#, Fetcher::LinuxDebian; "deb")]
483+
#[test_case(r#""apk""#, Fetcher::LinuxAlpine; "apk")]
431484
#[test]
432-
fn parses_org_package_no_org() {
433-
let names = [
434-
Package::new("/name/foo"),
435-
Package::new("/name"),
436-
Package::new("abcd/1234/name"),
437-
Package::new("1abc2/name"),
438-
];
439-
for test in names {
440-
let input = &format!("{test}");
441-
let Ok((org_id, package)) = parse_org_package(input) else {
442-
panic!("must parse '{test}'")
443-
};
444-
assert_eq!(org_id, None, "'org_id' must be None in '{test}'");
445-
assert_eq!(package, test, "'package' must match in '{test}");
446-
}
485+
fn serializes_linux_properly(expected: &str, value: Fetcher) {
486+
assert_eq!(expected, serde_json::to_string(&value).unwrap());
487+
}
488+
489+
#[test_case(Package::new("name"); "name")]
490+
#[test_case(Package::new("name/foo"); "name/foo")]
491+
#[test_case(Package::new("/name/foo"); "/name/foo")]
492+
#[test_case(Package::new("/name"); "/name")]
493+
#[test_case(Package::new("abcd/1234/name"); "abcd/1234/name")]
494+
#[test_case(Package::new("1abc2/name"); "1abc2/name")]
495+
#[test]
496+
fn package_roundtrip(package: Package) {
497+
let serialized = serde_json::to_string(&package).expect("must serialize");
498+
let deserialized = serde_json::from_str(&serialized).expect("must deserialize");
499+
assert_eq!(package, deserialized);
500+
}
501+
502+
#[test_case("1.0.0", revision!(semver => "1.0.0"); "1.0.0")]
503+
#[test_case("1.2.0", revision!(semver => "1.2.0"); "1.2.0")]
504+
#[test_case("1.0.0-alpha.1", revision!(semver => "1.0.0-alpha.1"); "1.0.0-alpha.1")]
505+
#[test_case("1.0.0-alpha1", revision!(semver => "1.0.0-alpha1"); "1.0.0-alpha1")]
506+
#[test_case("1.0.0-rc.10+r1234", revision!(semver => "1.0.0-rc.10+r1234"); "1.0.0-rc.10+r1234")]
507+
#[test_case("abcd1234", revision!(opaque => "abcd1234"); "abcd1234")]
508+
#[test_case("v1.0.0", revision!(opaque => "v1.0.0"); "v1.0.0")]
509+
#[test]
510+
fn revision(revision: &str, expected: Revision) {
511+
let serialized = serde_json::to_string(&revision).expect("must serialize");
512+
let deserialized = serde_json::from_str(&serialized).expect("must deserialize");
513+
assert_eq!(expected, deserialized);
514+
}
515+
516+
#[test_case(Revision::from("1.0.0"); "1.0.0")]
517+
#[test_case(Revision::from("1.2.0"); "1.2.0")]
518+
#[test_case(Revision::from("1.0.0-alpha.1"); "1.0.0-alpha.1")]
519+
#[test_case(Revision::from("1.0.0-alpha1"); "1.0.0-alpha1")]
520+
#[test_case(Revision::from("1.0.0-rc.10"); "1.0.0-rc.10")]
521+
#[test_case(Revision::from("abcd1234"); "abcd1234")]
522+
#[test_case(Revision::from("v1.0.0"); "v1.0.0")]
523+
#[test]
524+
fn revision_roundtrip(revision: Revision) {
525+
let serialized = serde_json::to_string(&revision).expect("must serialize");
526+
let deserialized = serde_json::from_str(&serialized).expect("must deserialize");
527+
assert_eq!(revision, deserialized);
447528
}
448529

530+
#[test_case(OrgId(1); "1")]
531+
#[test_case(OrgId(0); "0")]
532+
#[test_case(OrgId(1210931039); "1210931039")]
449533
#[test]
450-
fn serializes_linux_properly() {
451-
assert_eq!(
452-
r#""rpm-generic""#,
453-
serde_json::to_string(&Fetcher::LinuxRpm).unwrap()
454-
);
455-
assert_eq!(
456-
r#""deb""#,
457-
serde_json::to_string(&Fetcher::LinuxDebian).unwrap()
458-
);
459-
assert_eq!(
460-
r#""apk""#,
461-
serde_json::to_string(&Fetcher::LinuxAlpine).unwrap()
462-
);
534+
fn org_roundtrip(org: OrgId) {
535+
let serialized = serde_json::to_string(&org).expect("must serialize");
536+
let deserialized = serde_json::from_str(&serialized).expect("must deserialize");
537+
assert_eq!(org, deserialized);
463538
}
464539
}

0 commit comments

Comments
 (0)