feat(repo): missing $type? invent one

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-05-31 13:02:11 +03:00
committed by Tangled
parent 31ee12ecd3
commit 9b58961bba
3 changed files with 200 additions and 3 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
use super::validation::validate_record_with_status;
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
use crate::repo::record::write::CommitInfo;
use crate::repo::record::write::{CommitInfo, ensure_record_type};
use axum::{Json, extract::State};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
@@ -54,6 +54,8 @@ async fn process_single_write(
rkey,
value,
} => {
let value = ensure_record_type(value, collection);
let value = &*value;
let validation_status = if validate.should_skip() {
None
} else {
@@ -117,6 +119,8 @@ async fn process_single_write(
rkey,
value,
} => {
let value = ensure_record_type(value, collection);
let value = &*value;
let validation_status = if validate.should_skip() {
None
} else {
+32 -2
View File
@@ -5,6 +5,7 @@ use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::borrow::Cow;
use std::str::FromStr;
use tracing::error;
use tranquil_pds::api::error::{ApiError, DbResultExt};
@@ -62,6 +63,29 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
})
}
pub(crate) fn ensure_record_type<'a>(
record: &'a serde_json::Value,
collection: &Nsid,
) -> Cow<'a, serde_json::Value> {
let serde_json::Value::Object(map) = record else {
return Cow::Borrowed(record);
};
let needs_fill = match map.get("$type") {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(existing)) => existing.is_empty(),
Some(_) => false,
};
if !needs_fill {
return Cow::Borrowed(record);
}
let mut map = map.clone();
map.insert(
"$type".to_string(),
serde_json::Value::String(collection.to_string()),
);
Cow::Owned(serde_json::Value::Object(map))
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct CreateRecordInput {
@@ -95,8 +119,11 @@ pub struct CreateRecordOutput {
pub async fn create_record(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<CreateRecordInput>,
Json(mut input): Json<CreateRecordInput>,
) -> Result<Json<CreateRecordOutput>, ApiError> {
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
input.record = record;
}
let scope_proof = auth.verify_repo_create(&input.collection)?;
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
let did = repo_auth.did;
@@ -278,8 +305,11 @@ pub struct PutRecordOutput {
pub async fn put_record(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<PutRecordInput>,
Json(mut input): Json<PutRecordInput>,
) -> Result<Json<PutRecordOutput>, ApiError> {
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
input.record = record;
}
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
let did = repo_auth.did;
@@ -773,3 +773,166 @@ async fn test_list_records_comprehensive() {
.expect("Failed with nonexistent repo");
assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_missing_type_is_filled_from_collection() {
let client = client();
let (did, jwt) = setup_new_user("missing-type").await;
let now = Utc::now().to_rfc3339();
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "text": "no type set", "createdAt": now }
}))
.send()
.await
.expect("Failed to create record without $type");
assert_eq!(
create_res.status(),
StatusCode::OK,
"createRecord should fill missing $type from collection"
);
let create_body: Value = create_res.json().await.unwrap();
let create_rkey = create_body["uri"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
let get_created = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", &create_rkey),
])
.send()
.await
.expect("Failed to get created record");
let created_body: Value = get_created.json().await.unwrap();
assert_eq!(created_body["value"]["$type"], "app.bsky.feed.post");
let put_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.actor.profile",
"rkey": "self",
"record": { "displayName": "No Type" }
}))
.send()
.await
.expect("Failed to put record without $type");
assert_eq!(
put_res.status(),
StatusCode::OK,
"putRecord should fill missing $type from collection"
);
let get_put = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get put record");
let put_body: Value = get_put.json().await.unwrap();
assert_eq!(put_body["value"]["$type"], "app.bsky.actor.profile");
let apply_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"writes": [
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-no-type", "value": { "text": "batch no type", "createdAt": now } }
]
}))
.send()
.await
.expect("Failed to apply writes without $type");
assert_eq!(
apply_res.status(),
StatusCode::OK,
"applyWrites should fill missing $type from collection"
);
let get_batch = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "batch-no-type"),
])
.send()
.await
.expect("Failed to get batch record");
let batch_body: Value = get_batch.json().await.unwrap();
assert_eq!(batch_body["value"]["$type"], "app.bsky.feed.post");
let mismatch_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "$type": "app.bsky.feed.like", "text": "wrong type", "createdAt": now }
}))
.send()
.await
.expect("Failed to send mismatch request");
assert_eq!(
mismatch_res.status(),
StatusCode::BAD_REQUEST,
"explicit mismatched $type should still be rejected"
);
let non_string_type_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "$type": 123, "text": "non-string type", "createdAt": now }
}))
.send()
.await
.expect("Failed to send non-string type request");
assert_eq!(
non_string_type_res.status(),
StatusCode::BAD_REQUEST,
"present non-string $type should be rejected, not overwritten"
);
}