mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-06 10:16:59 +00:00
Repo conf. vs ref
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use super::validation::validate_record_with_rkey;
|
||||
use super::validation::validate_record_with_status;
|
||||
use super::write::has_verified_comms_channel;
|
||||
use crate::validation::ValidationStatus;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
@@ -56,9 +57,19 @@ pub struct ApplyWritesInput {
|
||||
#[serde(tag = "$type")]
|
||||
pub enum WriteResult {
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#createResult")]
|
||||
CreateResult { uri: String, cid: String },
|
||||
CreateResult {
|
||||
uri: String,
|
||||
cid: String,
|
||||
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
|
||||
validation_status: Option<String>,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
|
||||
UpdateResult { uri: String, cid: String },
|
||||
UpdateResult {
|
||||
uri: String,
|
||||
cid: String,
|
||||
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
|
||||
validation_status: Option<String>,
|
||||
},
|
||||
#[serde(rename = "com.atproto.repo.applyWrites#deleteResult")]
|
||||
DeleteResult {},
|
||||
}
|
||||
@@ -303,12 +314,20 @@ pub async fn apply_writes(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) =
|
||||
validate_record_with_rkey(value, collection, rkey.as_deref())
|
||||
{
|
||||
return *err_response;
|
||||
}
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
None
|
||||
} else {
|
||||
let require_lexicon = input.validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
rkey.as_deref(),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return *err_response,
|
||||
}
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let rkey = rkey
|
||||
.clone()
|
||||
@@ -345,6 +364,11 @@ pub async fn apply_writes(
|
||||
results.push(WriteResult::CreateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
});
|
||||
ops.push(RecordOp::Create {
|
||||
collection: collection.clone(),
|
||||
@@ -357,12 +381,20 @@ pub async fn apply_writes(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) =
|
||||
validate_record_with_rkey(value, collection, Some(rkey))
|
||||
{
|
||||
return *err_response;
|
||||
}
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
None
|
||||
} else {
|
||||
let require_lexicon = input.validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
value,
|
||||
collection,
|
||||
Some(rkey),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return *err_response,
|
||||
}
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
|
||||
@@ -397,6 +429,11 @@ pub async fn apply_writes(
|
||||
results.push(WriteResult::UpdateResult {
|
||||
uri,
|
||||
cid: record_cid.to_string(),
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
});
|
||||
ops.push(RecordOp::Update {
|
||||
collection: collection.clone(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::api::repo::record::write::prepare_repo_write;
|
||||
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
@@ -12,7 +12,7 @@ use axum::{
|
||||
use cid::Cid;
|
||||
use jacquard::types::string::Nsid;
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -29,6 +29,13 @@ pub struct DeleteRecordInput {
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteRecordOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<CommitInfo>,
|
||||
}
|
||||
|
||||
pub async fn delete_record(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -106,7 +113,11 @@ pub async fn delete_record(
|
||||
}
|
||||
let prev_record_cid = mst.get(&key).await.ok().flatten();
|
||||
if prev_record_cid.is_none() {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(DeleteRecordOutput { commit: None }),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let new_mst = match mst.delete(&key).await {
|
||||
Ok(m) => m,
|
||||
@@ -158,7 +169,7 @@ pub async fn delete_record(
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(
|
||||
let commit_result = match commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
@@ -173,11 +184,14 @@ pub async fn delete_record(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -198,5 +212,14 @@ pub async fn delete_record(
|
||||
.await;
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(DeleteRecordOutput {
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ pub async fn get_record(
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Record not found"})),
|
||||
Json(json!({"error": "RecordNotFound", "message": "Record not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -170,7 +170,7 @@ pub async fn get_record(
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Record CID mismatch"})),
|
||||
Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::validation::{RecordValidator, ValidationError};
|
||||
use crate::validation::{RecordValidator, ValidationError, ValidationStatus};
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
@@ -16,35 +16,88 @@ pub fn validate_record_with_rkey(
|
||||
rkey: Option<&str>,
|
||||
) -> Result<(), Box<Response>> {
|
||||
let validator = RecordValidator::new();
|
||||
validation_error_to_response(validator.validate_with_rkey(record, collection, rkey))
|
||||
}
|
||||
|
||||
pub fn validate_record_with_status(
|
||||
record: &serde_json::Value,
|
||||
collection: &str,
|
||||
rkey: Option<&str>,
|
||||
require_lexicon: bool,
|
||||
) -> Result<ValidationStatus, Box<Response>> {
|
||||
let validator = RecordValidator::new().require_lexicon(require_lexicon);
|
||||
match validator.validate_with_rkey(record, collection, rkey) {
|
||||
Ok(status) => Ok(status),
|
||||
Err(e) => Err(validation_error_to_box_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_error_to_response(
|
||||
result: Result<ValidationStatus, ValidationError>,
|
||||
) -> Result<(), Box<Response>> {
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ValidationError::MissingType) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
|
||||
).into_response())),
|
||||
Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
|
||||
).into_response())),
|
||||
Err(ValidationError::MissingField(field)) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
|
||||
).into_response())),
|
||||
Err(ValidationError::InvalidField { path, message }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
|
||||
).into_response())),
|
||||
Err(ValidationError::InvalidDatetime { path }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
|
||||
).into_response())),
|
||||
Err(ValidationError::BannedContent { path }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})),
|
||||
).into_response())),
|
||||
Err(e) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
|
||||
).into_response())),
|
||||
Err(e) => Err(validation_error_to_box_response(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_error_to_box_response(e: ValidationError) -> Box<Response> {
|
||||
match e {
|
||||
ValidationError::MissingType => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::TypeMismatch { expected, actual } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::MissingField(field) => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::InvalidField { path, message } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::InvalidDatetime { path } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::BannedContent { path } => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
ValidationError::UnknownType(type_name) => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
e => Box::new(
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::validation::validate_record_with_rkey;
|
||||
use super::validation::validate_record_with_status;
|
||||
use crate::validation::ValidationStatus;
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
|
||||
use crate::delegation::{self, DelegationActionType};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
@@ -183,11 +184,21 @@ pub struct CreateRecordInput {
|
||||
#[serde(rename = "swapCommit")]
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitInfo {
|
||||
pub cid: String,
|
||||
pub rev: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateRecordOutput {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
pub commit: CommitInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub validation_status: Option<String>,
|
||||
}
|
||||
pub async fn create_record(
|
||||
State(state): State<AppState>,
|
||||
@@ -256,12 +267,20 @@ pub async fn create_record(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) =
|
||||
validate_record_with_rkey(&input.record, &input.collection, input.rkey.as_deref())
|
||||
{
|
||||
return *err_response;
|
||||
}
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
None
|
||||
} else {
|
||||
let require_lexicon = input.validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
&input.record,
|
||||
&input.collection,
|
||||
input.rkey.as_deref(),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return *err_response,
|
||||
}
|
||||
};
|
||||
let rkey = input
|
||||
.rkey
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
@@ -336,7 +355,7 @@ pub async fn create_record(
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
if let Err(e) = commit_and_log(
|
||||
let commit_result = match commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
@@ -351,11 +370,14 @@ pub async fn create_record(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -381,6 +403,15 @@ pub async fn create_record(
|
||||
Json(CreateRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
},
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
@@ -403,6 +434,10 @@ pub struct PutRecordInput {
|
||||
pub struct PutRecordOutput {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<CommitInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub validation_status: Option<String>,
|
||||
}
|
||||
pub async fn put_record(
|
||||
State(state): State<AppState>,
|
||||
@@ -480,12 +515,20 @@ pub async fn put_record(
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, input.rkey);
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) =
|
||||
validate_record_with_rkey(&input.record, &input.collection, Some(&input.rkey))
|
||||
{
|
||||
return *err_response;
|
||||
}
|
||||
let validation_status = if input.validate == Some(false) {
|
||||
None
|
||||
} else {
|
||||
let require_lexicon = input.validate == Some(true);
|
||||
match validate_record_with_status(
|
||||
&input.record,
|
||||
&input.collection,
|
||||
Some(&input.rkey),
|
||||
require_lexicon,
|
||||
) {
|
||||
Ok(status) => Some(status),
|
||||
Err(err_response) => return *err_response,
|
||||
}
|
||||
};
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
@@ -512,6 +555,22 @@ pub async fn put_record(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if existing_cid == Some(record_cid) {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(PutRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: None,
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let new_mst = if existing_cid.is_some() {
|
||||
match mst.update(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
@@ -587,7 +646,7 @@ pub async fn put_record(
|
||||
.collect::<Vec<_>>();
|
||||
let is_update = existing_cid.is_some();
|
||||
let blob_cids = extract_blob_cids(&input.record);
|
||||
if let Err(e) = commit_and_log(
|
||||
let commit_result = match commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
@@ -602,11 +661,14 @@ pub async fn put_record(
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref controller) = controller_did {
|
||||
@@ -632,6 +694,15 @@ pub async fn put_record(
|
||||
Json(PutRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
commit: Some(CommitInfo {
|
||||
cid: commit_result.commit_cid.to_string(),
|
||||
rev: commit_result.rev,
|
||||
}),
|
||||
validation_status: validation_status.map(|s| match s {
|
||||
ValidationStatus::Valid => "valid".to_string(),
|
||||
ValidationStatus::Unknown => "unknown".to_string(),
|
||||
ValidationStatus::Invalid => "invalid".to_string(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
mod common;
|
||||
mod helpers;
|
||||
use chrono::Utc;
|
||||
use common::*;
|
||||
use helpers::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_record_response_schema() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-create").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Testing conformance",
|
||||
"createdAt": now
|
||||
}
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create record");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
assert!(body["uri"].is_string(), "response must have uri");
|
||||
assert!(body["cid"].is_string(), "response must have cid");
|
||||
assert!(body["cid"].as_str().unwrap().starts_with("bafy"), "cid must be valid");
|
||||
|
||||
assert!(body["commit"].is_object(), "response must have commit object");
|
||||
let commit = &body["commit"];
|
||||
assert!(commit["cid"].is_string(), "commit must have cid");
|
||||
assert!(commit["cid"].as_str().unwrap().starts_with("bafy"), "commit.cid must be valid");
|
||||
assert!(commit["rev"].is_string(), "commit must have rev");
|
||||
|
||||
assert!(body["validationStatus"].is_string(), "response must have validationStatus when validate defaults to true");
|
||||
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_record_no_validation_status_when_validate_false() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-create-noval").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"validate": false,
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Testing without validation",
|
||||
"createdAt": now
|
||||
}
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create record");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
assert!(body["uri"].is_string());
|
||||
assert!(body["commit"].is_object());
|
||||
assert!(body["validationStatus"].is_null(), "validationStatus should be omitted when validate=false");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_record_response_schema() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-put").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "conformance-put",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Testing putRecord conformance",
|
||||
"createdAt": now
|
||||
}
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put record");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
assert!(body["uri"].is_string(), "response must have uri");
|
||||
assert!(body["cid"].is_string(), "response must have cid");
|
||||
|
||||
assert!(body["commit"].is_object(), "response must have commit object");
|
||||
let commit = &body["commit"];
|
||||
assert!(commit["cid"].is_string(), "commit must have cid");
|
||||
assert!(commit["rev"].is_string(), "commit must have rev");
|
||||
|
||||
assert_eq!(body["validationStatus"], "valid", "validationStatus should be 'valid'");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_record_response_schema() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-delete").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let create_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "to-delete",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "This will be deleted",
|
||||
"createdAt": now
|
||||
}
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create record");
|
||||
assert_eq!(create_res.status(), StatusCode::OK);
|
||||
|
||||
let delete_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "to-delete"
|
||||
});
|
||||
let delete_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&delete_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to delete record");
|
||||
|
||||
assert_eq!(delete_res.status(), StatusCode::OK);
|
||||
let body: Value = delete_res.json().await.unwrap();
|
||||
|
||||
assert!(body["commit"].is_object(), "response must have commit object when record was deleted");
|
||||
let commit = &body["commit"];
|
||||
assert!(commit["cid"].is_string(), "commit must have cid");
|
||||
assert!(commit["rev"].is_string(), "commit must have rev");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_record_noop_response() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-delete-noop").await;
|
||||
|
||||
let delete_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "nonexistent-record"
|
||||
});
|
||||
let delete_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&delete_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to delete record");
|
||||
|
||||
assert_eq!(delete_res.status(), StatusCode::OK);
|
||||
let body: Value = delete_res.json().await.unwrap();
|
||||
|
||||
assert!(body["commit"].is_null(), "commit should be omitted on no-op delete");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_writes_response_schema() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-apply").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"writes": [
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "apply-test-1",
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "First post",
|
||||
"createdAt": now
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "apply-test-2",
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Second post",
|
||||
"createdAt": now
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to apply writes");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
assert!(body["commit"].is_object(), "response must have commit object");
|
||||
let commit = &body["commit"];
|
||||
assert!(commit["cid"].is_string(), "commit must have cid");
|
||||
assert!(commit["rev"].is_string(), "commit must have rev");
|
||||
|
||||
assert!(body["results"].is_array(), "response must have results array");
|
||||
let results = body["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 2, "should have 2 results");
|
||||
|
||||
for result in results {
|
||||
assert!(result["uri"].is_string(), "result must have uri");
|
||||
assert!(result["cid"].is_string(), "result must have cid");
|
||||
assert_eq!(result["validationStatus"], "valid", "result must have validationStatus");
|
||||
assert_eq!(result["$type"], "com.atproto.repo.applyWrites#createResult");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_writes_update_and_delete_results() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-apply-upd").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let create_payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "to-update",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Original",
|
||||
"createdAt": now
|
||||
}
|
||||
});
|
||||
client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&create_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("setup failed");
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"writes": [
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#update",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "to-update",
|
||||
"value": {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Updated",
|
||||
"createdAt": now
|
||||
}
|
||||
},
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#delete",
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "to-update"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to apply writes");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
let results = body["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
let update_result = &results[0];
|
||||
assert_eq!(update_result["$type"], "com.atproto.repo.applyWrites#updateResult");
|
||||
assert!(update_result["uri"].is_string());
|
||||
assert!(update_result["cid"].is_string());
|
||||
assert_eq!(update_result["validationStatus"], "valid");
|
||||
|
||||
let delete_result = &results[1];
|
||||
assert_eq!(delete_result["$type"], "com.atproto.repo.applyWrites#deleteResult");
|
||||
assert!(delete_result["uri"].is_null(), "delete result should not have uri");
|
||||
assert!(delete_result["cid"].is_null(), "delete result should not have cid");
|
||||
assert!(delete_result["validationStatus"].is_null(), "delete result should not have validationStatus");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_record_error_code() {
|
||||
let client = client();
|
||||
let (did, _jwt) = setup_new_user("conform-get-err").await;
|
||||
|
||||
let res = client
|
||||
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
|
||||
.query(&[
|
||||
("repo", did.as_str()),
|
||||
("collection", "app.bsky.feed.post"),
|
||||
("rkey", "nonexistent"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get record");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["error"], "RecordNotFound", "error code should be RecordNotFound per atproto spec");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_record_unknown_lexicon_default_validation() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-unknown-lex").await;
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "com.example.custom",
|
||||
"record": {
|
||||
"$type": "com.example.custom",
|
||||
"data": "some custom data"
|
||||
}
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create record");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK, "unknown lexicon should be allowed with default validation");
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
assert!(body["uri"].is_string());
|
||||
assert!(body["cid"].is_string());
|
||||
assert!(body["commit"].is_object());
|
||||
assert_eq!(body["validationStatus"], "unknown", "validationStatus should be 'unknown' for unknown lexicons");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_record_unknown_lexicon_strict_validation() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-unknown-strict").await;
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "com.example.custom",
|
||||
"validate": true,
|
||||
"record": {
|
||||
"$type": "com.example.custom",
|
||||
"data": "some custom data"
|
||||
}
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "unknown lexicon should fail with validate=true");
|
||||
let body: Value = res.json().await.unwrap();
|
||||
assert_eq!(body["error"], "InvalidRecord");
|
||||
assert!(body["message"].as_str().unwrap().contains("Lexicon not found"), "error should mention lexicon not found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_record_noop_same_content() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-put-noop").await;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let record = json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "This content will not change",
|
||||
"createdAt": now
|
||||
});
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": "noop-test",
|
||||
"record": record.clone()
|
||||
});
|
||||
|
||||
let first_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put record");
|
||||
assert_eq!(first_res.status(), StatusCode::OK);
|
||||
let first_body: Value = first_res.json().await.unwrap();
|
||||
assert!(first_body["commit"].is_object(), "first put should have commit");
|
||||
|
||||
let second_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put record");
|
||||
assert_eq!(second_res.status(), StatusCode::OK);
|
||||
let second_body: Value = second_res.json().await.unwrap();
|
||||
|
||||
assert!(second_body["commit"].is_null(), "second put with same content should have no commit (no-op)");
|
||||
assert_eq!(first_body["cid"], second_body["cid"], "CID should be the same for identical content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_writes_unknown_lexicon() {
|
||||
let client = client();
|
||||
let (did, jwt) = setup_new_user("conform-apply-unknown").await;
|
||||
|
||||
let payload = json!({
|
||||
"repo": did,
|
||||
"writes": [
|
||||
{
|
||||
"$type": "com.atproto.repo.applyWrites#create",
|
||||
"collection": "com.example.custom",
|
||||
"rkey": "custom-1",
|
||||
"value": {
|
||||
"$type": "com.example.custom",
|
||||
"data": "custom data"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to apply writes");
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body: Value = res.json().await.unwrap();
|
||||
|
||||
let results = body["results"].as_array().unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0]["validationStatus"], "unknown", "unknown lexicon should have 'unknown' status");
|
||||
}
|
||||
Reference in New Issue
Block a user