Move to SPDX licenses (#449)

* Move to SPDX licenses

Found a way to do this without breaking API compat, so here it is, instead of waiting for v3

Resolves MOD-129
Resolves #396

* License URL updates

* what was I thinking

* Do a thing

* Add open source filter

* Remove dead imports

* Borrow

* Update 20220910132835_spdx-licenses.sql

* Add license text route

* Update migration

* Address comments
This commit is contained in:
triphora 2022-11-29 23:53:24 -05:00 committed by GitHub
parent 34688852a4
commit 820519b4f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 748 additions and 1007 deletions

10
Cargo.lock generated
View File

@ -1566,6 +1566,7 @@ dependencies = [
"serde_with",
"sha1 0.6.1",
"sha2 0.9.9",
"spdx",
"sqlx",
"thiserror",
"tokio",
@ -2781,6 +2782,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "spdx"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a346909b3fd07776f9b96b98d4a58e3666f831c9a672c279b10f795a34c36425"
dependencies = [
"smallvec",
]
[[package]]
name = "spin"
version = "0.5.2"

View File

@ -59,6 +59,7 @@ itertools = "0.10.5"
validator = { version = "0.16.0", features = ["derive", "phone"] }
regex = "1.6.0"
censor = "0.2.0"
spdx = { version = "0.9.0", features = ["text"] }
dotenvy = "0.15.6"
log = "0.4.17"

View File

@ -0,0 +1,27 @@
ALTER TABLE mods ADD COLUMN license_new varchar(2048) DEFAULT 'LicenseRef-All-Rights-Reserved' NOT NULL;
UPDATE mods SET license_new = licenses.short FROM licenses WHERE mods.license = licenses.id;
UPDATE mods SET license_new = 'LicenseRef-Custom' WHERE license_new = 'custom';
UPDATE mods SET license_new = 'LicenseRef-All-Rights-Reserved' WHERE license_new = 'arr';
UPDATE mods SET license_new = 'Apache-2.0' WHERE license_new = 'apache';
UPDATE mods SET license_new = 'BSD-2-Clause' WHERE license_new = 'bsd-2-clause';
UPDATE mods SET license_new = 'BSD-3-Clause' WHERE license_new = 'bsd-3-clause' OR license_new = 'bsd';
UPDATE mods SET license_new = 'CC0-1.0' WHERE license_new = 'cc0';
UPDATE mods SET license_new = 'Unlicense' WHERE license_new = 'unlicense';
UPDATE mods SET license_new = 'MIT' WHERE license_new = 'mit';
UPDATE mods SET license_new = 'LGPL-3.0-only' WHERE license_new = 'lgpl-3';
UPDATE mods SET license_new = 'LGPL-2.1-only' WHERE license_new = 'lgpl-2.1' OR license_new = 'lgpl';
UPDATE mods SET license_new = 'MPL-2.0' WHERE license_new = 'mpl-2';
UPDATE mods SET license_new = 'ISC' WHERE license_new = 'isc';
UPDATE mods SET license_new = 'Zlib' WHERE license_new = 'zlib';
UPDATE mods SET license_new = 'GPL-2.0-only' WHERE license_new = 'gpl-2';
UPDATE mods SET license_new = 'GPL-3.0-only' WHERE license_new = 'gpl-3';
UPDATE mods SET license_new = 'AGPL-3.0-only' WHERE license_new = 'agpl';
UPDATE mods SET license_url = NULL WHERE license_url LIKE 'https://cdn.modrinth.com/licenses/%';
ALTER TABLE mods DROP COLUMN license;
ALTER TABLE mods RENAME COLUMN license_new TO license;
DROP TABLE licenses;

File diff suppressed because it is too large Load Diff

View File

@ -38,12 +38,6 @@ pub struct ReportType {
pub report_type: String,
}
pub struct License {
pub id: LicenseId,
pub short: String,
pub name: String,
}
pub struct DonationPlatform {
pub id: DonationPlatformId,
pub short: String,
@ -685,157 +679,6 @@ impl<'a> GameVersionBuilder<'a> {
}
}
#[derive(Default)]
pub struct LicenseBuilder<'a> {
pub short: Option<&'a str>,
pub name: Option<&'a str>,
}
impl License {
pub fn builder() -> LicenseBuilder<'static> {
LicenseBuilder::default()
}
pub async fn get_id<'a, E>(
id: &str,
exec: E,
) -> Result<Option<LicenseId>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id FROM licenses
WHERE short = $1
",
id
)
.fetch_optional(exec)
.await?;
Ok(result.map(|r| LicenseId(r.id)))
}
pub async fn get<'a, E>(
id: LicenseId,
exec: E,
) -> Result<License, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT short, name FROM licenses
WHERE id = $1
",
id as LicenseId
)
.fetch_one(exec)
.await?;
Ok(License {
id,
short: result.short,
name: result.name,
})
}
pub async fn list<'a, E>(exec: E) -> Result<Vec<License>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
SELECT id, short, name FROM licenses
"
)
.fetch_many(exec)
.try_filter_map(|e| async {
Ok(e.right().map(|c| License {
id: LicenseId(c.id),
short: c.short,
name: c.name,
}))
})
.try_collect::<Vec<License>>()
.await?;
Ok(result)
}
pub async fn remove<'a, E>(
short: &str,
exec: E,
) -> Result<Option<()>, DatabaseError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
DELETE FROM licenses
WHERE short = $1
",
short
)
.execute(exec)
.await?;
if result.rows_affected() == 0 {
// Nothing was deleted
Ok(None)
} else {
Ok(Some(()))
}
}
}
impl<'a> LicenseBuilder<'a> {
/// The license's short name/abbreviation. Spaces must be replaced with '_' for it to be valid
pub fn short(
self,
short: &'a str,
) -> Result<LicenseBuilder<'a>, DatabaseError> {
Ok(Self {
short: Some(short),
..self
})
}
/// The license's long name
pub fn name(
self,
name: &'a str,
) -> Result<LicenseBuilder<'a>, DatabaseError> {
Ok(Self {
name: Some(name),
..self
})
}
pub async fn insert<'b, E>(
self,
exec: E,
) -> Result<LicenseId, DatabaseError>
where
E: sqlx::Executor<'b, Database = sqlx::Postgres>,
{
let result = sqlx::query!(
"
INSERT INTO licenses (short, name)
VALUES ($1, $2)
ON CONFLICT (short) DO NOTHING
RETURNING id
",
self.short,
self.name,
)
.fetch_one(exec)
.await?;
Ok(LicenseId(result.id))
}
}
#[derive(Default)]
pub struct DonationPlatformBuilder<'a> {
pub short: Option<&'a str>,

View File

@ -131,9 +131,6 @@ pub struct StatusId(pub i32);
pub struct SideTypeId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct LicenseId(pub i32);
#[derive(Copy, Clone, Debug, Type)]
#[sqlx(transparent)]
pub struct DonationPlatformId(pub i32);
#[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash)]

View File

@ -92,7 +92,7 @@ pub struct ProjectBuilder {
pub status: StatusId,
pub client_side: SideTypeId,
pub server_side: SideTypeId,
pub license: LicenseId,
pub license: String,
pub slug: Option<String>,
pub donation_urls: Vec<DonationUrl>,
pub gallery_items: Vec<GalleryItem>,
@ -201,7 +201,7 @@ pub struct Project {
pub discord_url: Option<String>,
pub client_side: SideTypeId,
pub server_side: SideTypeId,
pub license: LicenseId,
pub license: String,
pub slug: Option<String>,
pub moderation_message: Option<String>,
pub moderation_message_body: Option<String>,
@ -247,7 +247,7 @@ impl Project {
self.client_side as SideTypeId,
self.server_side as SideTypeId,
self.license_url.as_ref(),
self.license as LicenseId,
&self.license,
self.slug.as_ref(),
self.project_type as ProjectTypeId
)
@ -301,7 +301,7 @@ impl Project {
client_side: SideTypeId(row.client_side),
status: StatusId(row.status),
server_side: SideTypeId(row.server_side),
license: LicenseId(row.license),
license: row.license,
slug: row.slug,
body: row.body,
follows: row.follows,
@ -362,7 +362,7 @@ impl Project {
client_side: SideTypeId(m.client_side),
status: StatusId(m.status),
server_side: SideTypeId(m.server_side),
license: LicenseId(m.license),
license: m.license,
slug: m.slug,
body: m.body,
follows: m.follows,
@ -649,7 +649,7 @@ impl Project {
m.updated updated, m.approved approved, m.status status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, l.name license_name, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user,
s.status status_name, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user,
ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories,
ARRAY_AGG(DISTINCT v.id || ' |||| ' || v.date_published) filter (where v.id is not null) versions,
ARRAY_AGG(DISTINCT mg.image_url || ' |||| ' || mg.featured || ' |||| ' || mg.created || ' |||| ' || COALESCE(mg.title, ' ') || ' |||| ' || COALESCE(mg.description, ' ')) filter (where mg.image_url is not null) gallery,
@ -659,7 +659,6 @@ impl Project {
INNER JOIN statuses s ON s.id = m.status
INNER JOIN side_types cs ON m.client_side = cs.id
INNER JOIN side_types ss ON m.server_side = ss.id
INNER JOIN licenses l ON m.license = l.id
LEFT JOIN mods_donations md ON md.joining_mod_id = m.id
LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.id
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
@ -667,7 +666,7 @@ impl Project {
LEFT JOIN versions v ON v.mod_id = m.id
LEFT JOIN mods_gallery mg ON mg.mod_id = m.id
WHERE m.id = $1
GROUP BY pt.id, s.id, cs.id, ss.id, l.id, m.id;
GROUP BY pt.id, s.id, cs.id, ss.id, m.id;
",
id as ProjectId,
)
@ -712,7 +711,7 @@ impl Project {
client_side: SideTypeId(m.client_side),
status: StatusId(m.status),
server_side: SideTypeId(m.server_side),
license: LicenseId(m.license),
license: m.license.clone(),
slug: m.slug.clone(),
body: m.body.clone(),
follows: m.follows,
@ -806,8 +805,6 @@ impl Project {
status: crate::models::projects::ProjectStatus::from_str(
&m.status_name,
),
license_id: m.short,
license_name: m.license_name,
client_side: crate::models::projects::SideType::from_str(
&m.client_side_type,
),
@ -838,7 +835,7 @@ impl Project {
m.updated updated, m.approved approved, m.status status,
m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,
m.team_id team_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, l.name license_name, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user,
s.status status_name, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.flame_anvil_project flame_anvil_project, m.flame_anvil_user flame_anvil_user,
ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories,
ARRAY_AGG(DISTINCT v.id || ' |||| ' || v.date_published) filter (where v.id is not null) versions,
ARRAY_AGG(DISTINCT mg.image_url || ' |||| ' || mg.featured || ' |||| ' || mg.created || ' |||| ' || COALESCE(mg.title, ' ') || ' |||| ' || COALESCE(mg.description, ' ')) filter (where mg.image_url is not null) gallery,
@ -848,7 +845,6 @@ impl Project {
INNER JOIN statuses s ON s.id = m.status
INNER JOIN side_types cs ON m.client_side = cs.id
INNER JOIN side_types ss ON m.server_side = ss.id
INNER JOIN licenses l ON m.license = l.id
LEFT JOIN mods_donations md ON md.joining_mod_id = m.id
LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.id
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
@ -856,7 +852,7 @@ impl Project {
LEFT JOIN versions v ON v.mod_id = m.id
LEFT JOIN mods_gallery mg ON mg.mod_id = m.id
WHERE m.id = ANY($1)
GROUP BY pt.id, s.id, cs.id, ss.id, l.id, m.id;
GROUP BY pt.id, s.id, cs.id, ss.id, m.id;
",
&project_ids_parsed
)
@ -903,7 +899,7 @@ impl Project {
client_side: SideTypeId(m.client_side),
status: StatusId(m.status),
server_side: SideTypeId(m.server_side),
license: LicenseId(m.license),
license: m.license.clone(),
slug: m.slug.clone(),
body: m.body.clone(),
follows: m.follows,
@ -983,8 +979,6 @@ impl Project {
})
.collect(),
status: crate::models::projects::ProjectStatus::from_str(&m.status_name),
license_id: m.short,
license_name: m.license_name,
client_side: crate::models::projects::SideType::from_str(&m.client_side_type),
server_side: crate::models::projects::SideType::from_str(&m.server_side_type),
}}))
@ -1004,8 +998,6 @@ pub struct QueryProject {
pub donation_urls: Vec<DonationUrl>,
pub gallery_items: Vec<GalleryItem>,
pub status: crate::models::projects::ProjectStatus,
pub license_id: String,
pub license_name: String,
pub client_side: crate::models::projects::SideType,
pub server_side: crate::models::projects::SideType,
}

View File

@ -121,8 +121,24 @@ impl From<QueryProject> for Project {
None
},
license: License {
id: data.license_id,
name: data.license_name,
id: m.license.clone(),
name: match spdx::Expression::parse(&*m.license) {
Ok(spdx_expr) => {
let mut vec: Vec<&str> = Vec::new();
for node in spdx_expr.iter() {
if let spdx::expression::ExprNode::Req(req) = node {
if let Some(id) = req.req.license.id() {
vec.push(id.full_name);
}
}
}
// spdx crate returns AND/OR operations in postfix order
// and it would be a lot more effort to make it actually in order
// so let's just ignore that and make them comma-separated
vec.join(", ")
}
Err(_) => "".to_string(),
},
url: m.license_url,
},
client_side: data.client_side,
@ -215,6 +231,8 @@ impl SideType {
}
}
pub const DEFAULT_LICENSE_ID: &str = "LicenseRef-All-Rights-Reserved";
#[derive(Serialize, Deserialize, Clone)]
pub struct License {
pub id: String,

View File

@ -688,16 +688,16 @@ pub async fn project_create_inner(
)
})?;
let license_id = models::categories::License::get_id(
let license_id = spdx::Expression::parse(
&project_create_data.license_id,
&mut *transaction,
)
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
"License specified does not exist.".to_string(),
)
.map_err(|err| {
CreateError::InvalidInput(format!(
"Invalid SPDX license identifier: {}",
err
))
})?;
let mut donation_urls = vec![];
if let Some(urls) = &project_create_data.donation_urls {
@ -744,7 +744,7 @@ pub async fn project_create_inner(
status: status_id,
client_side: client_side_id,
server_side: server_side_id,
license: license_id,
license: license_id.to_string(),
slug: Some(project_create_data.slug),
donation_urls,
gallery_items: gallery_urls

View File

@ -857,12 +857,18 @@ pub async fn project_edit(
));
}
let license_id = database::models::categories::License::get_id(
license,
&mut *transaction,
)
.await?
.expect("No database entry found for license");
let mut license = license.clone();
if license.to_lowercase() == "arr" {
license = models::projects::DEFAULT_LICENSE_ID.to_string();
}
spdx::Expression::parse(&*license).map_err(|err| {
ApiError::InvalidInput(format!(
"Invalid SPDX license identifier: {}",
err
))
})?;
sqlx::query!(
"
@ -870,7 +876,7 @@ pub async fn project_edit(
SET license = $1
WHERE (id = $2)
",
license_id as database::models::LicenseId,
license,
id as database::models::ids::ProjectId,
)
.execute(&mut *transaction)

View File

@ -1,7 +1,7 @@
use super::ApiError;
use crate::database::models;
use crate::database::models::categories::{
DonationPlatform, License, ProjectType, ReportType,
DonationPlatform, ProjectType, ReportType,
};
use crate::util::auth::check_is_admin_from_headers;
use actix_web::{delete, get, put, web, HttpRequest, HttpResponse};
@ -21,9 +21,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(game_version_list)
.service(game_version_create)
.service(game_version_delete)
.service(license_create)
.service(license_delete)
.service(license_list)
.service(license_text)
.service(donation_platform_create)
.service(donation_platform_list)
.service(donation_platform_delete)
@ -302,75 +301,52 @@ pub async fn game_version_delete(
}
#[derive(serde::Serialize)]
pub struct LicenseQueryData {
pub struct License {
short: String,
name: String,
}
#[get("license")]
pub async fn license_list(
pool: web::Data<PgPool>,
) -> Result<HttpResponse, ApiError> {
let results: Vec<LicenseQueryData> = License::list(&**pool)
.await?
.into_iter()
.map(|x| LicenseQueryData {
short: x.short,
name: x.name,
})
.collect();
Ok(HttpResponse::Ok().json(results))
}
pub async fn license_list() -> HttpResponse {
let licenses = spdx::identifiers::LICENSES;
let mut results: Vec<License> = Vec::with_capacity(licenses.len());
#[derive(serde::Deserialize)]
pub struct LicenseData {
name: String,
}
#[put("license/{name}")]
pub async fn license_create(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
license_data: web::Json<LicenseData>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let short = license.into_inner().0;
let _id = License::builder()
.short(&short)?
.name(&license_data.name)?
.insert(&**pool)
.await?;
Ok(HttpResponse::NoContent().body(""))
}
#[delete("license/{name}")]
pub async fn license_delete(
req: HttpRequest,
pool: web::Data<PgPool>,
license: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
check_is_admin_from_headers(req.headers(), &**pool).await?;
let name = license.into_inner().0;
let mut transaction =
pool.begin().await.map_err(models::DatabaseError::from)?;
let result = License::remove(&name, &mut transaction).await?;
transaction
.commit()
.await
.map_err(models::DatabaseError::from)?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))
} else {
Ok(HttpResponse::NotFound().body(""))
for (short, name, _) in licenses {
results.push(License {
short: short.to_string(),
name: name.to_string(),
});
}
HttpResponse::Ok().json(results)
}
#[derive(serde::Serialize)]
pub struct LicenseText {
body: String,
}
#[get("license/{id}")]
pub async fn license_text(
params: web::Path<(String,)>,
) -> Result<HttpResponse, ApiError> {
let license_id = params.into_inner().0;
if license_id == crate::models::projects::DEFAULT_LICENSE_ID.to_string() {
return Ok(HttpResponse::Ok().json(LicenseText {
body: "All rights reserved unless explicitly stated.".to_string(),
}));
}
if let Some(license) = spdx::license_id(&*license_id) {
return Ok(HttpResponse::Ok().json(LicenseText {
body: license.text().to_string(),
}));
}
Err(ApiError::InvalidInput(
"Invalid SPDX identifier specified".to_string(),
))
}
#[derive(serde::Serialize)]

View File

@ -35,8 +35,6 @@ pub fn tags_config(cfg: &mut web::ServiceConfig) {
.service(tags::game_version_list)
.service(super::tags::game_version_create)
.service(super::tags::game_version_delete)
.service(super::tags::license_create)
.service(super::tags::license_delete)
.service(super::tags::license_list)
.service(super::tags::donation_platform_create)
.service(super::tags::donation_platform_list)

View File

@ -16,7 +16,7 @@ pub async fn index_local(
SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,
m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,
m.team_id team_id, m.license license, m.slug slug,
s.status status_name, cs.name client_side_type, ss.name server_side_type, l.short short, pt.name project_type_name, u.username username,
s.status status_name, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username,
ARRAY_AGG(DISTINCT c.category || ' |||| ' || mc.is_additional) filter (where c.category is not null) categories,
ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,
ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions,
@ -34,11 +34,10 @@ pub async fn index_local(
INNER JOIN project_types pt ON pt.id = m.project_type
INNER JOIN side_types cs ON m.client_side = cs.id
INNER JOIN side_types ss ON m.server_side = ss.id
INNER JOIN licenses l ON m.license = l.id
INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE
INNER JOIN users u ON tm.user_id = u.id
WHERE s.status = $1 OR s.status = $2
GROUP BY m.id, s.id, cs.id, ss.id, l.id, pt.id, u.id;
GROUP BY m.id, s.id, cs.id, ss.id, pt.id, u.id;
",
crate::models::projects::ProjectStatus::Approved.as_str(),
crate::models::projects::ProjectStatus::Archived.as_str(),
@ -73,6 +72,16 @@ pub async fn index_local(
let project_id: crate::models::projects::ProjectId = ProjectId(m.id).into();
let license = match m.license.split(" ").next() {
Some(license) => license.to_string(),
None => m.license,
};
let open_source = match spdx::license_id(&license) {
Some(id) => id.is_osi_approved(),
_ => false,
};
UploadSearchProject {
project_id: format!("{}", project_id),
title: m.title,
@ -88,13 +97,14 @@ pub async fn index_local(
modified_timestamp: m.updated.timestamp(),
latest_version: versions.last().cloned().unwrap_or_else(|| "None".to_string()),
versions,
license: m.short,
license,
client_side: m.client_side_type,
server_side: m.server_side_type,
slug: m.slug,
project_type: m.project_type_name,
gallery: m.gallery.unwrap_or_default(),
display_categories
display_categories,
open_source,
}
}))
})

View File

@ -217,6 +217,7 @@ const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[
"date_created",
"date_modified",
"project_id",
"open_source",
];
const DEFAULT_SORTABLE_ATTRIBUTES: &[&str] =

View File

@ -97,6 +97,7 @@ pub struct UploadSearchProject {
pub date_modified: DateTime<Utc>,
/// Unix timestamp of the last major modification
pub modified_timestamp: i64,
pub open_source: bool,
}
#[derive(Serialize, Deserialize, Debug)]