use deno_core::anyhow::Context;
use deno_core::anyhow::bail;
use deno_core::error::AnyError;
use deno_core::serde_json;
use deno_core::url::Url;
use deno_runtime::deno_fetch;
use deno_semver::jsr::JsrPackageReqReference;
use serde::de::DeserializeOwned;
use crate::http_util;
use crate::http_util::HttpClient;
use crate::util::console::escape_terminal_control_chars;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAuthorizationResponse {
pub verification_url: String,
pub code: String,
pub exchange_token: String,
pub poll_interval: u64,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExchangeAuthorizationResponse {
pub token: String,
pub user: User,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub name: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OidcTokenResponse {
pub value: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishingTaskError {
#[allow(dead_code, reason = "currently unused")]
pub code: String,
pub message: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishingTask {
pub id: String,
pub status: String,
pub error: Option<PublishingTaskError>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiError {
pub code: String,
pub message: String,
#[serde(flatten)]
pub data: serde_json::Value,
#[serde(skip)]
pub x_deno_ray: Option<String>,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({})",
escape_terminal_control_chars(&self.message),
escape_terminal_control_chars(&self.code)
)?;
if let Some(x_deno_ray) = &self.x_deno_ray {
write!(
f,
"[x-deno-ray: {}]",
escape_terminal_control_chars(x_deno_ray)
)?;
}
Ok(())
}
}
impl std::fmt::Debug for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(self, f)
}
}
impl std::error::Error for ApiError {}
pub async fn parse_response<T: DeserializeOwned>(
response: http::Response<deno_fetch::ResBody>,
) -> Result<T, ApiError> {
let status = response.status();
let x_deno_ray = response
.headers()
.get("x-deno-ray")
.and_then(|value| value.to_str().ok())
.map(|s| s.to_string());
let text = http_util::body_to_string(response).await.unwrap();
if !status.is_success() {
match serde_json::from_str::<ApiError>(&text) {
Ok(mut err) => {
err.x_deno_ray = x_deno_ray;
return Err(err);
}
Err(_) => {
let err = ApiError {
code: "unknown".to_string(),
message: format!("{}: {}", status, text),
x_deno_ray,
data: serde_json::json!({}),
};
return Err(err);
}
}
}
serde_json::from_str(&text).map_err(|err| ApiError {
code: "unknown".to_string(),
message: format!("Failed to parse response: {}, response: '{}'", err, text),
x_deno_ray,
data: serde_json::json!({}),
})
}
pub fn get_package_api_url(
registry_api_url: &Url,
scope: &str,
package: &str,
) -> Result<Url, AnyError> {
append_path_segments(
registry_api_url,
&["scopes", scope, "packages", package],
)
}
pub fn get_package_version_api_url(
registry_api_url: &Url,
scope: &str,
package: &str,
version: &str,
config_path: Option<&str>,
) -> Result<Url, AnyError> {
let mut url = append_path_segments(
registry_api_url,
&["scopes", scope, "packages", package, "versions", version],
)?;
if let Some(config_path) = config_path {
url.query_pairs_mut().append_pair("config", config_path);
}
Ok(url)
}
pub fn get_package_version_provenance_api_url(
registry_api_url: &Url,
scope: &str,
package: &str,
version: &str,
) -> Result<Url, AnyError> {
append_path_segments(
registry_api_url,
&[
"scopes",
scope,
"packages",
package,
"versions",
version,
"provenance",
],
)
}
fn append_path_segments(
base_url: &Url,
segments: &[&str],
) -> Result<Url, AnyError> {
let mut url = base_url.clone();
url.set_query(None);
url.set_fragment(None);
url
.path_segments_mut()
.map_err(|_| {
deno_core::anyhow::anyhow!(
"Registry API URL cannot be used as a base URL"
)
})?
.pop_if_empty()
.extend(segments);
Ok(url)
}
pub async fn get_package(
client: &HttpClient,
registry_api_url: &Url,
scope: &str,
package: &str,
authorization: Option<&str>,
) -> Result<http::Response<deno_fetch::ResBody>, AnyError> {
let package_url = get_package_api_url(registry_api_url, scope, package)?;
let mut request = client.get(package_url)?;
if let Some(authorization) = authorization {
request = request.header(
http::header::AUTHORIZATION,
authorization
.parse()
.context("Failed to parse authorization header")?,
);
}
let response = request.send().await?;
Ok(response)
}
pub fn parse_package_name(name: &str) -> Result<(&str, &str), AnyError> {
let reference = JsrPackageReqReference::from_str(&format!("jsr:{name}@*"))
.map_err(|_| {
deno_core::anyhow::anyhow!(
"package name must use the '@<scope>/<package>' format"
)
})?;
if reference.sub_path().is_some() {
bail!("package name must not contain additional path segments");
}
let Some((scope, package)) =
name.strip_prefix('@').and_then(|name| name.split_once('/'))
else {
bail!("package name must use the '@<scope>/<package>' format");
};
for component in [scope, package] {
if component == "." || component == ".." {
bail!("package name must not contain dot path segments");
}
if component
.chars()
.any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%'))
{
bail!("package name contains a URL path or delimiter character");
}
}
Ok((scope, package))
}
pub async fn check_version_exists(
client: &HttpClient,
registry_api_url: &Url,
scope: &str,
package: &str,
version: &str,
) -> Result<bool, AnyError> {
let url = get_package_version_api_url(
registry_api_url,
scope,
package,
version,
None,
)?;
let response = client.get(url)?.send().await?;
Ok(response.status() == 200)
}
pub fn get_jsr_alternative(imported: &Url) -> Option<String> {
if matches!(imported.host_str(), Some("esm.sh")) {
let mut segments = imported.path_segments()?;
match segments.next()? {
"gh" => None,
module => Some(format!("\"npm:{module}\"")),
}
} else if imported.as_str().starts_with("https://deno.land/") {
let mut segments = imported.path_segments()?;
let maybe_std = segments.next()?;
if maybe_std != "std" && !maybe_std.starts_with("std@") {
return None;
}
let module = segments.next()?;
let export = segments
.next()
.filter(|s| *s != "mod.ts")
.map(|s| s.strip_suffix(".ts").unwrap_or(s).replace("_", "-"));
Some(format!(
"\"jsr:@std/{}@1{}\"",
module,
export.map(|s| format!("/{}", s)).unwrap_or_default()
))
} else {
None
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_jsr_alternative() {
#[track_caller]
fn run_test(imported: &str, output: Option<&str>) {
let imported = Url::parse(imported).unwrap();
let output = output.map(|s| s.to_string());
assert_eq!(get_jsr_alternative(&imported), output);
}
run_test("https://esm.sh/ts-morph", Some("\"npm:ts-morph\""));
run_test(
"https://deno.land/std/path/mod.ts",
Some("\"jsr:@std/path@1\""),
);
run_test(
"https://deno.land/std/path/join.ts",
Some("\"jsr:@std/path@1/join\""),
);
run_test(
"https://deno.land/std@0.229.0/path/join.ts",
Some("\"jsr:@std/path@1/join\""),
);
run_test(
"https://deno.land/std@0.229.0/path/something_underscore.ts",
Some("\"jsr:@std/path@1/something-underscore\""),
);
}
#[test]
fn test_parse_package_name() {
assert_eq!(parse_package_name("@deno/doc").unwrap(), ("deno", "doc"));
assert_eq!(
parse_package_name("@scope-1/package-2").unwrap(),
("scope-1", "package-2")
);
assert_eq!(parse_package_name("@a/b").unwrap(), ("a", "b"));
for invalid_name in [
"deno/doc",
"@deno",
"@deno/../doc",
"@deno/doc/other",
"@deno/doc?other",
"@deno/doc#other",
"@deno/doc\\other",
"@deno/doc%2Fother",
] {
assert!(
parse_package_name(invalid_name).is_err(),
"{invalid_name} should be invalid"
);
}
}
#[test]
fn package_api_urls_use_path_segments() {
let base = Url::parse("https://registry.example/custom/api/").unwrap();
let package_url = get_package_api_url(&base, "scope", "package").unwrap();
assert_eq!(
package_url.as_str(),
"https://registry.example/custom/api/scopes/scope/packages/package"
);
let version_url = get_package_version_api_url(
&base,
"scope",
"package",
"1.2.3+build.1",
Some("/deno.json"),
)
.unwrap();
assert_eq!(
version_url.path(),
"/custom/api/scopes/scope/packages/package/versions/1.2.3+build.1"
);
assert_eq!(
version_url.query_pairs().collect::<Vec<_>>(),
vec![("config".into(), "/deno.json".into())]
);
let provenance_url = get_package_version_provenance_api_url(
&base, "scope", "package", "1.2.3",
)
.unwrap();
assert_eq!(
provenance_url.as_str(),
"https://registry.example/custom/api/scopes/scope/packages/package/versions/1.2.3/provenance"
);
}
#[test]
fn package_api_url_components_cannot_change_the_endpoint() {
let base = Url::parse("https://registry.example/custom/api/").unwrap();
let url = get_package_version_api_url(
&base,
"scope/../../other",
"package?mode=other#fragment",
"1.0.0/../../other",
None,
)
.unwrap();
assert_eq!(
url.origin().ascii_serialization(),
"https://registry.example"
);
assert_eq!(
url.as_str(),
"https://registry.example/custom/api/scopes/scope%2F..%2F..%2Fother/packages/package%3Fmode=other%23fragment/versions/1.0.0%2F..%2F..%2Fother"
);
}
#[test]
fn api_error_display_escapes_terminal_controls() {
let err = ApiError {
code: "bad\u{202e}code".to_string(),
message: "failed\x1b[2J\nagain".to_string(),
data: serde_json::json!({}),
x_deno_ray: Some("ray\u{009b}31m".to_string()),
};
let display = err.to_string();
assert_eq!(
display,
r"failed\u{1b}[2J\nagain (bad\u{202e}code)[x-deno-ray: ray\u{9b}31m]"
);
assert_eq!(format!("{err:?}"), display);
assert_eq!(err.code, "bad\u{202e}code");
}
}