feat(papermc_api): implement wrapper for PaperMC builds API

This commit is contained in:
Jef Roosens 2026-03-31 19:14:17 +02:00
parent f2a0b6230f
commit b4dce4b69a
No known key found for this signature in database
GPG key ID: 21FD3D77D56BAF49
7 changed files with 952 additions and 24 deletions

10
papermc-api/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "papermc-api"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
chrono.workspace = true
serde_json = "1.0.149"
ureq = { version = "3.3.0", features = ["json"] }

View file

@ -0,0 +1,19 @@
fn main() {
let client = papermc_api::Client::new();
let projects = client.projects().unwrap();
for project in projects {
println!("project: {:?}", project);
}
let versions = client.project("paper").versions().unwrap();
for version in versions {
println!("version: {:?}", version);
}
let latest = client.project("paper").version("1.21.1").latest().unwrap();
println!("latest: {:?}", latest);
let builds = client.project("paper").version("1.21.10").builds().unwrap();
println!("number of builds: {}", builds.len());
}

22
papermc-api/src/error.rs Normal file
View file

@ -0,0 +1,22 @@
#[derive(Debug)]
pub enum Error {
Ureq(ureq::Error),
BadBody,
}
impl std::error::Error for Error {}
impl From<ureq::Error> for Error {
fn from(value: ureq::Error) -> Self {
Self::Ureq(value)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ureq(err) => err.fmt(f),
Self::BadBody => f.write_str("bad response body"),
}
}
}

217
papermc-api/src/lib.rs Normal file
View file

@ -0,0 +1,217 @@
use serde_json::Value;
use crate::{
error::Error,
models::{Build, BuildCommit, BuildDownload, Java, Project, Version},
};
mod error;
mod models;
pub struct Client {
agent: ureq::Agent,
}
pub const BASE_URL: &str = "https://fill.papermc.io/v3";
impl Client {
pub fn new() -> Self {
Self {
agent: ureq::agent(),
}
}
pub fn projects(&self) -> Result<Vec<Project>, Error> {
let mut res = self.agent.get(format!("{}/projects", BASE_URL)).call()?;
let body_json: Value = res.body_mut().read_json()?;
let projects = body_json["projects"].as_array().ok_or(Error::BadBody)?;
projects
.into_iter()
.map(|p| {
Ok(Project {
id: p["project"]["id"]
.as_str()
.map(|s| s.to_string())
.ok_or(Error::BadBody)?,
name: p["project"]["name"]
.as_str()
.map(|s| s.to_string())
.ok_or(Error::BadBody)?,
// Flatten map of versions into one array
versions: p["versions"]
.as_object()
.ok_or(Error::BadBody)?
.into_iter()
.map(|(_, versions)| versions.as_array().ok_or(Error::BadBody))
// Collect into error to propagate error of any of the versions
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.map(|v| v.as_str().ok_or(Error::BadBody).map(|s| s.to_string()))
.collect::<Result<_, _>>()?,
})
})
.collect()
}
pub fn project<'a>(&'a self, project: &'a str) -> ProjectQuery<'a> {
return ProjectQuery {
agent: &self.agent,
project,
};
}
}
pub struct ProjectQuery<'a> {
agent: &'a ureq::Agent,
project: &'a str,
}
impl<'a> ProjectQuery<'a> {
pub fn versions(&self) -> Result<Vec<Version>, Error> {
let mut res = self
.agent
.get(format!("{}/projects/{}/versions", BASE_URL, self.project))
.call()?;
let body_json: Value = res.body_mut().read_json()?;
let versions = body_json["versions"].as_array().ok_or(Error::BadBody)?;
versions
.into_iter()
.map(|v| {
Ok(Version {
id: v["version"]["id"]
.as_str()
.map(String::from)
.ok_or(Error::BadBody)?,
support_status: v["version"]["support"]["status"]
.as_str()
.ok_or(Error::BadBody)?
.parse()
.map_err(|_| Error::BadBody)?,
java: Java {
minimum_version: v["version"]["java"]["version"]["minimum"]
.as_u64()
.ok_or(Error::BadBody)?,
recommended_flags: v["version"]["java"]["flags"]["recommended"]
.as_array()
.ok_or(Error::BadBody)?
.into_iter()
.map(|v| v.as_str().map(String::from).ok_or(Error::BadBody))
.collect::<Result<_, _>>()?,
},
builds: v["builds"]
.as_array()
.ok_or(Error::BadBody)?
.into_iter()
.map(|v| v.as_u64().ok_or(Error::BadBody))
.collect::<Result<_, _>>()?,
})
})
.collect()
}
pub fn version(&self, version: &'a str) -> VersionQuery<'a> {
VersionQuery {
agent: self.agent,
project: self.project,
version,
}
}
}
pub struct VersionQuery<'a> {
agent: &'a ureq::Agent,
project: &'a str,
version: &'a str,
}
impl<'a> VersionQuery<'a> {
pub fn latest(&self) -> Result<Build, Error> {
let mut res = self
.agent
.get(format!(
"{}/projects/{}/versions/{}/builds/latest",
BASE_URL, self.project, self.version
))
.call()?;
let body_json: Value = res.body_mut().read_json()?;
parse_build_json(&body_json)
}
pub fn builds(&self) -> Result<Vec<Build>, Error> {
let mut res = self
.agent
.get(format!(
"{}/projects/{}/versions/{}/builds",
BASE_URL, self.project, self.version
))
.call()?;
let body_json: Value = res.body_mut().read_json()?;
body_json
.as_array()
.ok_or(Error::BadBody)?
.into_iter()
.map(parse_build_json)
.collect()
}
}
fn parse_build_json(value: &Value) -> Result<Build, Error> {
Ok(Build {
id: value["id"].as_u64().ok_or(Error::BadBody)?,
time: chrono::DateTime::parse_from_rfc3339(value["time"].as_str().ok_or(Error::BadBody)?)
.map_err(|_| Error::BadBody)?
.into(),
channel: value["channel"]
.as_str()
.ok_or(Error::BadBody)?
.parse()
.map_err(|_| Error::BadBody)?,
commits: value["commits"]
.as_array()
.ok_or(Error::BadBody)?
.into_iter()
.map(|build| {
Ok(BuildCommit {
sha: build["sha"].as_str().ok_or(Error::BadBody)?.to_string(),
time: chrono::DateTime::parse_from_rfc3339(
build["time"].as_str().ok_or(Error::BadBody)?,
)
.map_err(|_| Error::BadBody)?
.into(),
message: build["message"].as_str().ok_or(Error::BadBody)?.to_string(),
})
})
.collect::<Result<_, Error>>()?,
downloads: value["downloads"]
.as_object()
.ok_or(Error::BadBody)?
.into_iter()
.map(|(key, build)| {
Ok((
key.to_string(),
BuildDownload {
name: build["name"].as_str().ok_or(Error::BadBody)?.to_string(),
size: build["size"].as_u64().ok_or(Error::BadBody)?,
url: build["url"].as_str().ok_or(Error::BadBody)?.to_string(),
checksums: build["checksums"]
.as_object()
.ok_or(Error::BadBody)?
.into_iter()
.map(|(key, value)| {
Ok((
key.to_string(),
value.as_str().ok_or(Error::BadBody)?.to_string(),
))
})
.collect::<Result<_, Error>>()?,
},
))
})
.collect::<Result<_, Error>>()?,
})
}

90
papermc-api/src/models.rs Normal file
View file

@ -0,0 +1,90 @@
use std::{collections::HashMap, str::FromStr};
use chrono::{DateTime, Utc};
#[derive(Debug)]
pub struct Project {
pub id: String,
pub name: String,
pub versions: Vec<String>,
}
#[derive(Debug)]
pub enum SupportStatus {
Supported,
Unsupported,
}
pub struct SupportStatusParseError;
impl FromStr for SupportStatus {
type Err = SupportStatusParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"SUPPORTED" => Ok(Self::Supported),
"UNSUPPORTED" => Ok(Self::Unsupported),
_ => Err(SupportStatusParseError),
}
}
}
#[derive(Debug)]
pub struct Java {
pub minimum_version: u64,
pub recommended_flags: Vec<String>,
}
#[derive(Debug)]
pub struct Version {
pub id: String,
pub support_status: SupportStatus,
pub java: Java,
pub builds: Vec<u64>,
}
#[derive(Debug)]
pub struct Build {
pub id: u64,
pub time: DateTime<Utc>,
pub channel: BuildChannel,
pub commits: Vec<BuildCommit>,
pub downloads: HashMap<String, BuildDownload>,
}
#[derive(Debug)]
pub enum BuildChannel {
Alpha,
Beta,
Stable,
}
pub struct BuildChannelParseError;
impl FromStr for BuildChannel {
type Err = BuildChannelParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"STABLE" => Ok(Self::Stable),
"BETA" => Ok(Self::Beta),
"ALPHA" => Ok(Self::Alpha),
_ => Err(BuildChannelParseError),
}
}
}
#[derive(Debug)]
pub struct BuildCommit {
pub sha: String,
pub time: DateTime<Utc>,
pub message: String,
}
#[derive(Debug)]
pub struct BuildDownload {
pub name: String,
pub checksums: HashMap<String, String>,
pub size: u64,
pub url: String,
}