Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add lexicon validation layer based on typescript atproto impl, validate records on create and update, update generate-typescript script to support record in create

+2184 -70
+3 -2
api/Cargo.lock
··· 2058 2058 2059 2059 [[package]] 2060 2060 name = "regex" 2061 - version = "1.11.1" 2061 + version = "1.11.2" 2062 2062 source = "registry+https://github.com/rust-lang/crates.io-index" 2063 - checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 2063 + checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" 2064 2064 dependencies = [ 2065 2065 "aho-corasick", 2066 2066 "memchr", ··· 2544 2544 "chrono", 2545 2545 "dotenvy", 2546 2546 "futures-util", 2547 + "regex", 2547 2548 "reqwest", 2548 2549 "reqwest-chain", 2549 2550 "reqwest-middleware",
+1
api/Cargo.toml
··· 55 55 56 56 # Job queue 57 57 sqlxmq = "0.6" 58 + regex = "1.11.2"
+13 -4
api/scripts/generate-typescript.ts
··· 926 926 ]); 927 927 } else if (method.name === "createRecord") { 928 928 methodDecl.addStatements([ 929 - `const recordWithType = { $type: '${collectionPath}', ...record };`, 930 - `const payload = useSelfRkey ? { ...recordWithType, rkey: 'self' } : recordWithType;`, 929 + `const recordValue = { $type: '${collectionPath}', ...record };`, 930 + `const payload = {`, 931 + ` slice: this.sliceUri,`, 932 + ` ...(useSelfRkey ? { rkey: 'self' } : {}),`, 933 + ` record: recordValue`, 934 + `};`, 931 935 `return await this.makeRequest<{ uri: string; cid: string }>('${collectionPath}.createRecord', 'POST', payload);`, 932 936 ]); 933 937 } else if (method.name === "updateRecord") { 934 938 methodDecl.addStatements([ 935 - `const recordWithType = { $type: '${collectionPath}', ...record };`, 936 - `return await this.makeRequest<{ uri: string; cid: string }>('${collectionPath}.updateRecord', 'POST', { rkey, record: recordWithType });`, 939 + `const recordValue = { $type: '${collectionPath}', ...record };`, 940 + `const payload = {`, 941 + ` slice: this.sliceUri,`, 942 + ` rkey,`, 943 + ` record: recordValue`, 944 + `};`, 945 + `return await this.makeRequest<{ uri: string; cid: string }>('${collectionPath}.updateRecord', 'POST', payload);`, 937 946 ]); 938 947 } else if (method.name === "deleteRecord") { 939 948 methodDecl.addStatements([
+2 -2
api/src/database.rs
··· 396 396 let definitions: serde_json::Value = serde_json::from_str(definitions_str).ok()?; 397 397 398 398 Some(serde_json::json!({ 399 - "nsid": nsid, 400 - "definitions": definitions 399 + "id": nsid, 400 + "defs": definitions 401 401 })) 402 402 }) 403 403 .collect();
+145 -18
api/src/handler_openapi_spec.rs
··· 143 143 let mut collection_lexicons = HashMap::new(); 144 144 if let Ok(lexicons) = state.database.get_lexicons_by_slice(&params.slice).await { 145 145 for lexicon in lexicons { 146 - if let Some(nsid) = lexicon.get("nsid").and_then(|v| v.as_str()) { 146 + if let Some(nsid) = lexicon.get("id").and_then(|v| v.as_str()) { 147 147 collection_lexicons.insert(nsid.to_string(), lexicon); 148 148 } 149 149 } ··· 405 405 406 406 // Generate schema from lexicon if available 407 407 let record_schema = if let Some(lexicon) = lexicon_data { 408 - create_record_schema_from_lexicon(Some(lexicon)) 408 + create_record_schema_from_lexicon(Some(lexicon), slice_uri) 409 409 } else { 410 410 OpenApiSchema { 411 411 schema_type: "object".to_string(), ··· 417 417 } 418 418 }; 419 419 420 + // Check if lexicon specifies a literal key for rkey default 421 + let rkey_default = if let Some(lexicon) = lexicon_data { 422 + if let Some(defs) = lexicon.get("defs") { 423 + if let Some(main_def) = defs.get("main") { 424 + // The "key" field is at the main level, not inside "record" 425 + if let Some(key) = main_def.get("key") { 426 + if let Some(key_str) = key.as_str() { 427 + if key_str.starts_with("literal:") { 428 + Some(serde_json::Value::String(key_str.strip_prefix("literal:").unwrap_or("").to_string())) 429 + } else { 430 + None 431 + } 432 + } else { 433 + None 434 + } 435 + } else { 436 + None 437 + } 438 + } else { 439 + None 440 + } 441 + } else { 442 + None 443 + } 444 + } else { 445 + None 446 + }; 447 + 448 + // Create the structured create schema with {slice, rkey, record} 449 + let create_schema = OpenApiSchema { 450 + schema_type: "object".to_string(), 451 + format: None, 452 + items: None, 453 + properties: Some({ 454 + let mut props = HashMap::new(); 455 + props.insert("slice".to_string(), OpenApiSchema { 456 + schema_type: "string".to_string(), 457 + format: Some("uri".to_string()), 458 + items: None, 459 + properties: None, 460 + required: None, 461 + default: Some(serde_json::Value::String(slice_uri.to_string())), 462 + }); 463 + props.insert("rkey".to_string(), OpenApiSchema { 464 + schema_type: "string".to_string(), 465 + format: None, 466 + items: None, 467 + properties: None, 468 + required: None, 469 + default: rkey_default.clone(), 470 + }); 471 + props.insert("record".to_string(), record_schema.clone()); 472 + props 473 + }), 474 + required: Some(vec!["slice".to_string(), "record".to_string()]), 475 + default: None, 476 + }; 477 + 420 478 create_operations.insert("post".to_string(), OpenApiOperation { 421 479 operation_id: format!("create{}", collection.replace(".", "_")), 422 480 summary: format!("Create {} record", collection), 423 481 description: format!("Create a new record in the {} collection", collection), 424 482 parameters: None, 425 483 request_body: Some(OpenApiRequestBody { 426 - description: "Record data to create".to_string(), 484 + description: "Structured request with slice URI, optional rkey, and record data".to_string(), 427 485 content: { 428 486 let mut content = HashMap::new(); 429 487 content.insert("application/json".to_string(), OpenApiMediaType { 430 - schema: record_schema.clone(), 488 + schema: create_schema, 431 489 }); 432 490 content 433 491 }, ··· 448 506 description: format!("Update an existing record in the {} collection", collection), 449 507 parameters: None, 450 508 request_body: Some(OpenApiRequestBody { 451 - description: "Record data and rkey to update".to_string(), 509 + description: "Record data, rkey, and slice URI to update".to_string(), 452 510 content: { 453 511 let mut content = HashMap::new(); 454 512 content.insert("application/json".to_string(), OpenApiMediaType { ··· 464 522 items: None, 465 523 properties: None, 466 524 required: None, 467 - default: None, 525 + default: rkey_default.clone(), 468 526 }); 469 527 props.insert("record".to_string(), record_schema.clone()); 528 + props.insert("slice".to_string(), OpenApiSchema { 529 + schema_type: "string".to_string(), 530 + format: Some("uri".to_string()), 531 + items: None, 532 + properties: None, 533 + required: None, 534 + default: Some(serde_json::Value::String(slice_uri.to_string())), 535 + }); 470 536 props 471 537 }), 472 - required: Some(vec!["rkey".to_string(), "record".to_string()]), 538 + required: Some(vec!["rkey".to_string(), "record".to_string(), "slice".to_string()]), 473 539 default: None, 474 540 }, 475 541 }); ··· 508 574 items: None, 509 575 properties: None, 510 576 required: None, 511 - default: None, 577 + default: rkey_default, 512 578 }); 513 579 props 514 580 }), ··· 805 871 } 806 872 } 807 873 808 - fn create_record_schema_from_lexicon(lexicon_data: Option<&serde_json::Value>) -> OpenApiSchema { 874 + fn create_record_schema_from_lexicon(lexicon_data: Option<&serde_json::Value>, slice_uri: &str) -> OpenApiSchema { 809 875 if let Some(lexicon) = lexicon_data { 810 876 // Get the definitions object directly (it's already parsed JSON, not a string) 811 - if let Some(definitions) = lexicon.get("definitions") { 877 + if let Some(definitions) = lexicon.get("defs") { 812 878 if let Some(main_def) = definitions.get("main") { 813 879 if let Some(record_def) = main_def.get("record") { 814 880 if let Some(properties) = record_def.get("properties") { ··· 816 882 let mut openapi_props = HashMap::new(); 817 883 let mut required_fields = Vec::new(); 818 884 819 - if let Some(props_obj) = properties.as_object() { 885 + // Get required fields from record level 886 + if let Some(required_array) = record_def.get("required") { 887 + if let Some(required_list) = required_array.as_array() { 888 + for req_field in required_list { 889 + if let Some(field_name) = req_field.as_str() { 890 + required_fields.push(field_name.to_string()); 891 + } 892 + } 893 + } 894 + } 895 + 896 + let default_example = if let Some(props_obj) = properties.as_object() { 820 897 for (prop_name, prop_def) in props_obj { 821 898 if let Some(prop_schema) = convert_lexicon_property_to_openapi(prop_def) { 822 899 openapi_props.insert(prop_name.clone(), prop_schema); 900 + } 901 + } 823 902 824 - // Check if field is required 825 - if let Some(required) = prop_def.get("required") { 826 - if required.as_bool().unwrap_or(false) { 827 - required_fields.push(prop_name.clone()); 828 - } 903 + // Create a default example object with required fields from lexicon 904 + if !required_fields.is_empty() { 905 + let mut example_obj = serde_json::Map::new(); 906 + for field_name in &required_fields { 907 + if let Some(prop_def) = props_obj.get(field_name) { 908 + let example_value = create_example_value_from_lexicon_prop(prop_def, field_name, slice_uri); 909 + example_obj.insert(field_name.clone(), example_value); 829 910 } 830 911 } 912 + Some(serde_json::Value::Object(example_obj)) 913 + } else { 914 + None 831 915 } 832 - } 916 + } else { 917 + None 918 + }; 833 919 834 920 return OpenApiSchema { 835 921 schema_type: "object".to_string(), ··· 837 923 items: None, 838 924 properties: Some(openapi_props), 839 925 required: if required_fields.is_empty() { None } else { Some(required_fields) }, 840 - default: None, 926 + default: default_example, 841 927 }; 842 928 } 843 929 } ··· 1005 1091 auth_requirement.insert("bearerAuth".to_string(), vec![]); 1006 1092 vec![auth_requirement] 1007 1093 } 1094 + 1095 + fn create_example_value_from_lexicon_prop(prop_def: &serde_json::Value, _field_name: &str, _slice_uri: &str) -> serde_json::Value { 1096 + // Generate example based on lexicon type and format 1097 + if let Some(prop_type) = prop_def.get("type").and_then(|t| t.as_str()) { 1098 + match prop_type { 1099 + "string" => { 1100 + // Check for format to provide more specific examples 1101 + if let Some(format) = prop_def.get("format").and_then(|f| f.as_str()) { 1102 + match format { 1103 + "datetime" => serde_json::Value::String("2025-01-01T00:00:00Z".to_string()), 1104 + "at-uri" => serde_json::Value::String("at://did:plc:example/collection/record".to_string()), 1105 + "at-identifier" => serde_json::Value::String("handle.example.com".to_string()), 1106 + "did" => serde_json::Value::String("did:plc:example123".to_string()), 1107 + "handle" => serde_json::Value::String("user.bsky.social".to_string()), 1108 + "nsid" => serde_json::Value::String("com.example.record".to_string()), 1109 + "cid" => serde_json::Value::String("bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua".to_string()), 1110 + "uri" => serde_json::Value::String("https://example.com".to_string()), 1111 + "language" => serde_json::Value::String("en".to_string()), 1112 + _ => serde_json::Value::String("example string".to_string()), 1113 + } 1114 + } else { 1115 + serde_json::Value::String("example string".to_string()) 1116 + } 1117 + }, 1118 + "integer" => serde_json::Value::Number(serde_json::Number::from(42)), 1119 + "boolean" => serde_json::Value::Bool(true), 1120 + "array" => serde_json::Value::Array(vec![]), 1121 + "object" => serde_json::Value::Object(serde_json::Map::new()), 1122 + "blob" => serde_json::json!({ 1123 + "$type": "blob", 1124 + "ref": {"$link": "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua"}, 1125 + "mimeType": "image/jpeg", 1126 + "size": 12345 1127 + }), 1128 + _ => serde_json::Value::String("example value".to_string()), 1129 + } 1130 + } else { 1131 + serde_json::Value::String("example value".to_string()) 1132 + } 1133 + } 1134 +
+226 -19
api/src/handler_xrpc_dynamic.rs
··· 8 8 use atproto_client::com::atproto::repo::{CreateRecordRequest, PutRecordRequest, DeleteRecordRequest, create_record, put_record, delete_record, CreateRecordResponse, PutRecordResponse}; 9 9 10 10 use crate::auth::{extract_bearer_token, verify_oauth_token, get_atproto_auth_for_user}; 11 + use crate::lexicon::LexiconValidator; 11 12 use crate::models::{ListRecordsOutput, Record}; 12 13 use crate::AppState; 13 14 ··· 251 252 } 252 253 } 253 254 254 - // Dynamic collection create (e.g., social.grain.gallery.create) 255 + // Dynamic collection create (e.g., social.grain.gallery.createRecord) 255 256 async fn dynamic_collection_create_impl( 256 257 state: AppState, 257 258 headers: HeaderMap, 258 259 body: serde_json::Value, 259 260 collection: String, 260 261 ) -> Result<Json<serde_json::Value>, StatusCode> { 261 - // Debug logging removed - slice creation is working 262 - 263 262 // Extract and verify OAuth token 264 263 let token = extract_bearer_token(&headers)?; 265 264 let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await?; ··· 273 272 // Create HTTP client 274 273 let http_client = reqwest::Client::new(); 275 274 276 - // Extract rkey if provided, otherwise let PDS generate 275 + // Extract slice URI, rkey, and record value from structured body 276 + let slice_uri = body.get("slice") 277 + .and_then(|v| v.as_str()) 278 + .ok_or(StatusCode::BAD_REQUEST)? 279 + .to_string(); 280 + 277 281 let record_key = body.get("rkey") 278 282 .and_then(|v| v.as_str()) 283 + .filter(|s| !s.is_empty()) // Filter out empty strings 279 284 .map(|s| s.to_string()); 280 - 281 - // Remove rkey from record data before sending to PDS 282 - let mut record_data = body.clone(); 283 - if record_data.is_object() { 284 - record_data.as_object_mut().unwrap().remove("rkey"); 285 + 286 + let record_data = body.get("record") 287 + .ok_or(StatusCode::BAD_REQUEST)? 288 + .clone(); 289 + 290 + 291 + // Validate the record against its lexicon 292 + 293 + // For social.slices.lexicon collection, validate against the system slice 294 + let validation_slice_uri = if collection == "social.slices.lexicon" { 295 + "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q" 296 + } else { 297 + &slice_uri 298 + }; 299 + 300 + 301 + match LexiconValidator::for_slice(&state.database, validation_slice_uri).await { 302 + Ok(validator) => { 303 + 304 + // Debug: Get lexicons from the system slice to see what's there 305 + if collection == "social.slices.lexicon" { 306 + } 307 + 308 + if let Err(e) = validator.validate_record(&collection, &record_data) { 309 + let error_response = serde_json::json!({ 310 + "error": "ValidationError", 311 + "message": format!("Lexicon validation failed: {}", e) 312 + }); 313 + return Ok(Json(error_response)); 314 + } 315 + }, 316 + Err(e) => { 317 + // If no lexicon found, continue without validation (backwards compatibility) 318 + eprintln!("Could not load lexicon validator: {:?}", e); 319 + } 285 320 } 286 321 287 322 // Create record using AT Protocol functions with DPoP 323 + 288 324 let create_request = CreateRecordRequest { 289 325 repo: repo.clone(), 290 326 collection: collection.clone(), 291 327 record_key, 292 - record: record_data, 328 + record: record_data.clone(), 293 329 swap_commit: None, 294 330 validate: false, 295 331 }; ··· 297 333 298 334 let result = create_record(&http_client, &dpop_auth, &pds_url, create_request) 299 335 .await 300 - .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?; 336 + .map_err(|_e| { 337 + StatusCode::INTERNAL_SERVER_ERROR 338 + })?; 301 339 302 340 // Extract URI and CID from the response enum 303 341 let (uri, cid) = match result { 304 - CreateRecordResponse::StrongRef { uri, cid, .. } => (uri, cid), 305 - CreateRecordResponse::Error(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), 342 + CreateRecordResponse::StrongRef { uri, cid, .. } => { 343 + (uri, cid) 344 + }, 345 + CreateRecordResponse::Error(_e) => { 346 + return Err(StatusCode::INTERNAL_SERVER_ERROR); 347 + }, 306 348 }; 307 349 308 350 // Also store in local database for indexing ··· 311 353 cid: cid.clone(), 312 354 did: repo, 313 355 collection, 314 - json: body, 356 + json: record_data, 315 357 indexed_at: Utc::now(), 316 358 }; 317 359 318 360 // Store in local database (ignore errors as AT Protocol operation succeeded) 319 - let _ = state.database.insert_record(&record).await; 361 + let _insert_result = state.database.insert_record(&record).await; 362 + // Store in local database (ignore errors as AT Protocol operation succeeded) 363 + let _insert_result = state.database.insert_record(&record).await; 320 364 321 365 Ok(Json(serde_json::json!({ 322 366 "uri": uri, ··· 324 368 }))) 325 369 } 326 370 327 - // Dynamic collection update (e.g., social.grain.gallery.update) 371 + // Dynamic collection update (e.g., social.grain.gallery.updateRecord) 328 372 async fn dynamic_collection_update_impl( 329 373 state: AppState, 330 374 headers: HeaderMap, ··· 338 382 // Get AT Protocol DPoP auth and PDS URL 339 383 let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await?; 340 384 341 - // Extract repo and rkey from body 385 + // Extract slice URI, rkey, and record value from structured body 386 + let slice_uri = body.get("slice") 387 + .and_then(|v| v.as_str()) 388 + .ok_or(StatusCode::BAD_REQUEST)? 389 + .to_string(); 390 + 391 + let rkey = body.get("rkey") 392 + .and_then(|v| v.as_str()) 393 + .ok_or(StatusCode::BAD_REQUEST)? 394 + .to_string(); 395 + 396 + let record_data = body.get("record") 397 + .ok_or(StatusCode::BAD_REQUEST)? 398 + .clone(); 399 + 400 + // Extract repo from user info 342 401 let repo = user_info.did.unwrap_or(user_info.sub); 343 - let rkey = body["rkey"].as_str().ok_or(StatusCode::BAD_REQUEST)?.to_string(); 344 - let record_data = body["record"].clone(); 402 + 403 + // Validate the record against its lexicon 404 + match LexiconValidator::for_slice(&state.database, &slice_uri).await { 405 + Ok(validator) => { 406 + if let Err(e) = validator.validate_record(&collection, &record_data) { 407 + let error_response = serde_json::json!({ 408 + "error": "ValidationError", 409 + "message": format!("Lexicon validation failed: {}", e) 410 + }); 411 + return Ok(Json(error_response)); 412 + } 413 + }, 414 + Err(e) => { 415 + // If no lexicon found, continue without validation (backwards compatibility) 416 + eprintln!("Could not load lexicon validator: {:?}", e); 417 + } 418 + } 345 419 346 420 // Create HTTP client 347 421 let http_client = reqwest::Client::new(); ··· 459 533 Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), 460 534 } 461 535 } 536 + 537 + #[cfg(test)] 538 + mod dynamic_validation_tests { 539 + use crate::lexicon::LexiconValidator; 540 + use serde_json::json; 541 + 542 + fn create_test_lexicons() -> Vec<serde_json::Value> { 543 + vec![ 544 + json!({ 545 + "id": "social.slices.testRecord", 546 + "lexicon": 1, 547 + "defs": { 548 + "main": { 549 + "type": "record", 550 + "record": { 551 + "type": "object", 552 + "required": ["title", "aspectRatio"], 553 + "properties": { 554 + "title": { "type": "string", "maxLength": 100 }, 555 + "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } 556 + } 557 + } 558 + }, 559 + "aspectRatio": { 560 + "type": "object", 561 + "required": ["width", "height"], 562 + "properties": { 563 + "width": { "type": "integer", "minimum": 1 }, 564 + "height": { "type": "integer", "minimum": 1 } 565 + } 566 + } 567 + } 568 + }) 569 + ] 570 + } 571 + 572 + #[test] 573 + fn test_valid_record_passes_validation() { 574 + let lexicons = create_test_lexicons(); 575 + let validator = LexiconValidator::new(lexicons).unwrap(); 576 + 577 + let valid_record = json!({ 578 + "title": "Test Record", 579 + "aspectRatio": { 580 + "width": 16, 581 + "height": 9 582 + } 583 + }); 584 + 585 + let result = validator.validate_record("social.slices.testRecord", &valid_record); 586 + assert!(result.is_ok(), "Valid record should pass validation: {:?}", result); 587 + } 588 + 589 + #[test] 590 + fn test_missing_required_field_fails_validation() { 591 + let lexicons = create_test_lexicons(); 592 + let validator = LexiconValidator::new(lexicons).unwrap(); 593 + 594 + let invalid_record = json!({ 595 + "title": "Test Record" 596 + // Missing required aspectRatio field 597 + }); 598 + 599 + let result = validator.validate_record("social.slices.testRecord", &invalid_record); 600 + assert!(result.is_err(), "Record missing required field should fail validation"); 601 + } 602 + 603 + #[test] 604 + fn test_cross_lexicon_reference_works() { 605 + let lexicons = create_test_lexicons(); 606 + let validator = LexiconValidator::new(lexicons).unwrap(); 607 + 608 + // This tests that the #aspectRatio reference resolves correctly 609 + let valid_record = json!({ 610 + "title": "Cross-ref test", 611 + "aspectRatio": { 612 + "width": 4, 613 + "height": 3 614 + } 615 + }); 616 + 617 + let result = validator.validate_record("social.slices.testRecord", &valid_record); 618 + assert!(result.is_ok(), "Cross-lexicon reference should work: {:?}", result); 619 + } 620 + 621 + #[test] 622 + fn test_validation_error_message_format() { 623 + let lexicons = create_test_lexicons(); 624 + let validator = LexiconValidator::new(lexicons).unwrap(); 625 + 626 + // Test invalid record to get error message 627 + let invalid_record = json!({ 628 + "title": "Test Record" 629 + // Missing required aspectRatio field 630 + }); 631 + 632 + let result = validator.validate_record("social.slices.testRecord", &invalid_record); 633 + assert!(result.is_err(), "Invalid record should fail validation"); 634 + 635 + if let Err(e) = result { 636 + let error_message = format!("{}", e); 637 + assert!(!error_message.is_empty(), "Error message should not be empty"); 638 + // Error message should be user-friendly and descriptive 639 + assert!(error_message.contains("aspectRatio") || error_message.contains("required"), 640 + "Error message should indicate what's wrong: {}", error_message); 641 + } 642 + } 643 + 644 + #[test] 645 + fn test_constraint_violation_error_message() { 646 + let lexicons = create_test_lexicons(); 647 + let validator = LexiconValidator::new(lexicons).unwrap(); 648 + 649 + // Test constraint violation (string too long) 650 + let invalid_record = json!({ 651 + "title": "A".repeat(150), // Exceeds maxLength of 100 652 + "aspectRatio": { 653 + "width": 16, 654 + "height": 9 655 + } 656 + }); 657 + 658 + let result = validator.validate_record("social.slices.testRecord", &invalid_record); 659 + assert!(result.is_err(), "Constraint violation should fail validation"); 660 + 661 + if let Err(e) = result { 662 + let error_message = format!("{}", e); 663 + // Should indicate the specific constraint that was violated 664 + assert!(error_message.contains("length") || error_message.contains("maximum") || error_message.contains("100"), 665 + "Error message should indicate length constraint: {}", error_message); 666 + } 667 + } 668 + }
+44
api/src/lexicon/errors.rs
··· 1 + use thiserror::Error; 2 + 3 + #[derive(Debug, Error)] 4 + pub enum ValidationError { 5 + #[error("Lexicon not found for collection: {0}")] 6 + LexiconNotFound(String), 7 + 8 + #[error("Invalid type at {path}: expected {expected}, got {actual}")] 9 + TypeMismatch { 10 + path: String, 11 + expected: String, 12 + actual: String, 13 + }, 14 + 15 + #[error("Required field missing at {path}")] 16 + RequiredFieldMissing { path: String }, 17 + 18 + #[error("String validation failed at {path}: {message}")] 19 + StringValidationFailed { path: String, message: String }, 20 + 21 + #[error("Integer validation failed at {path}: {message}")] 22 + IntegerValidationFailed { path: String, message: String }, 23 + 24 + #[error("Array validation failed at {path}: {message}")] 25 + ArrayValidationFailed { path: String, message: String }, 26 + 27 + #[error("Format validation failed at {path}: invalid {format} format")] 28 + FormatValidationFailed { path: String, format: String }, 29 + 30 + #[error("Enum validation failed at {path}: value not in allowed set")] 31 + EnumValidationFailed { path: String }, 32 + 33 + #[error("Unknown validation error at {path}: {message}")] 34 + Unknown { path: String, message: String }, 35 + 36 + #[error("Invalid lexicon schema: {0}")] 37 + InvalidSchema(String), 38 + 39 + #[error("Reference not found: {0}")] 40 + ReferenceNotFound(String), 41 + 42 + #[error("Union validation failed at {path}: no matching variant")] 43 + UnionValidationFailed { path: String }, 44 + }
+8
api/src/lexicon/mod.rs
··· 1 + pub mod errors; 2 + pub mod types; 3 + pub mod validator; 4 + 5 + #[cfg(test)] 6 + mod test; 7 + 8 + pub use validator::LexiconValidator;
+864
api/src/lexicon/test.rs
··· 1 + #[cfg(test)] 2 + mod tests { 3 + use super::super::*; 4 + use serde_json::json; 5 + 6 + // Test data that mirrors the TypeScript scaffold lexicons 7 + fn get_test_lexicons() -> Vec<serde_json::Value> { 8 + vec![ 9 + json!({ 10 + "lexicon": 1, 11 + "id": "com.example.kitchenSink", 12 + "defs": { 13 + "main": { 14 + "type": "record", 15 + "description": "A record", 16 + "key": "tid", 17 + "record": { 18 + "type": "object", 19 + "required": [ 20 + "object", 21 + "array", 22 + "boolean", 23 + "integer", 24 + "string", 25 + "bytes", 26 + "cidLink" 27 + ], 28 + "properties": { 29 + "object": { "type": "ref", "ref": "#object" }, 30 + "array": { "type": "array", "items": { "type": "string" } }, 31 + "boolean": { "type": "boolean" }, 32 + "integer": { "type": "integer" }, 33 + "string": { "type": "string" }, 34 + "bytes": { "type": "bytes" }, 35 + "cidLink": { "type": "cid-link" } 36 + } 37 + } 38 + }, 39 + "object": { 40 + "type": "object", 41 + "required": ["object", "array", "boolean", "integer", "string"], 42 + "properties": { 43 + "object": { "type": "ref", "ref": "#subobject" }, 44 + "array": { "type": "array", "items": { "type": "string" } }, 45 + "boolean": { "type": "boolean" }, 46 + "integer": { "type": "integer" }, 47 + "string": { "type": "string" } 48 + } 49 + }, 50 + "subobject": { 51 + "type": "object", 52 + "required": ["boolean"], 53 + "properties": { 54 + "boolean": { "type": "boolean" } 55 + } 56 + } 57 + } 58 + }), 59 + json!({ 60 + "lexicon": 1, 61 + "id": "com.example.arrayLength", 62 + "defs": { 63 + "main": { 64 + "type": "record", 65 + "record": { 66 + "type": "object", 67 + "properties": { 68 + "array": { 69 + "type": "array", 70 + "minLength": 2, 71 + "maxLength": 4, 72 + "items": { "type": "integer" } 73 + } 74 + } 75 + } 76 + } 77 + } 78 + }), 79 + json!({ 80 + "lexicon": 1, 81 + "id": "com.example.boolConst", 82 + "defs": { 83 + "main": { 84 + "type": "record", 85 + "record": { 86 + "type": "object", 87 + "properties": { 88 + "boolean": { 89 + "type": "boolean", 90 + "const": false 91 + } 92 + } 93 + } 94 + } 95 + } 96 + }), 97 + json!({ 98 + "lexicon": 1, 99 + "id": "com.example.integerRange", 100 + "defs": { 101 + "main": { 102 + "type": "record", 103 + "record": { 104 + "type": "object", 105 + "properties": { 106 + "integer": { 107 + "type": "integer", 108 + "minimum": 2, 109 + "maximum": 4 110 + } 111 + } 112 + } 113 + } 114 + } 115 + }), 116 + json!({ 117 + "lexicon": 1, 118 + "id": "com.example.integerEnum", 119 + "defs": { 120 + "main": { 121 + "type": "record", 122 + "record": { 123 + "type": "object", 124 + "properties": { 125 + "integer": { 126 + "type": "integer", 127 + "enum": [1, 2] 128 + } 129 + } 130 + } 131 + } 132 + } 133 + }), 134 + json!({ 135 + "lexicon": 1, 136 + "id": "com.example.integerConst", 137 + "defs": { 138 + "main": { 139 + "type": "record", 140 + "record": { 141 + "type": "object", 142 + "properties": { 143 + "integer": { 144 + "type": "integer", 145 + "const": 0 146 + } 147 + } 148 + } 149 + } 150 + } 151 + }), 152 + json!({ 153 + "lexicon": 1, 154 + "id": "com.example.stringLength", 155 + "defs": { 156 + "main": { 157 + "type": "record", 158 + "record": { 159 + "type": "object", 160 + "properties": { 161 + "string": { 162 + "type": "string", 163 + "minLength": 2, 164 + "maxLength": 4 165 + } 166 + } 167 + } 168 + } 169 + } 170 + }), 171 + json!({ 172 + "lexicon": 1, 173 + "id": "com.example.stringEnum", 174 + "defs": { 175 + "main": { 176 + "type": "record", 177 + "record": { 178 + "type": "object", 179 + "properties": { 180 + "string": { 181 + "type": "string", 182 + "enum": ["a", "b"] 183 + } 184 + } 185 + } 186 + } 187 + } 188 + }), 189 + json!({ 190 + "lexicon": 1, 191 + "id": "com.example.stringConst", 192 + "defs": { 193 + "main": { 194 + "type": "record", 195 + "record": { 196 + "type": "object", 197 + "properties": { 198 + "string": { 199 + "type": "string", 200 + "const": "a" 201 + } 202 + } 203 + } 204 + } 205 + } 206 + }), 207 + json!({ 208 + "lexicon": 1, 209 + "id": "com.example.datetime", 210 + "defs": { 211 + "main": { 212 + "type": "record", 213 + "record": { 214 + "type": "object", 215 + "properties": { 216 + "datetime": { "type": "string", "format": "datetime" } 217 + } 218 + } 219 + } 220 + } 221 + }), 222 + json!({ 223 + "lexicon": 1, 224 + "id": "com.example.uri", 225 + "defs": { 226 + "main": { 227 + "type": "record", 228 + "record": { 229 + "type": "object", 230 + "properties": { 231 + "uri": { "type": "string", "format": "uri" } 232 + } 233 + } 234 + } 235 + } 236 + }), 237 + json!({ 238 + "lexicon": 1, 239 + "id": "com.example.atUri", 240 + "defs": { 241 + "main": { 242 + "type": "record", 243 + "record": { 244 + "type": "object", 245 + "properties": { 246 + "atUri": { "type": "string", "format": "at-uri" } 247 + } 248 + } 249 + } 250 + } 251 + }), 252 + json!({ 253 + "lexicon": 1, 254 + "id": "com.example.did", 255 + "defs": { 256 + "main": { 257 + "type": "record", 258 + "record": { 259 + "type": "object", 260 + "properties": { 261 + "did": { "type": "string", "format": "did" } 262 + } 263 + } 264 + } 265 + } 266 + }), 267 + json!({ 268 + "lexicon": 1, 269 + "id": "com.example.handle", 270 + "defs": { 271 + "main": { 272 + "type": "record", 273 + "record": { 274 + "type": "object", 275 + "properties": { 276 + "handle": { "type": "string", "format": "handle" } 277 + } 278 + } 279 + } 280 + } 281 + }), 282 + json!({ 283 + "lexicon": 1, 284 + "id": "com.example.atIdentifier", 285 + "defs": { 286 + "main": { 287 + "type": "record", 288 + "record": { 289 + "type": "object", 290 + "properties": { 291 + "atIdentifier": { "type": "string", "format": "at-identifier" } 292 + } 293 + } 294 + } 295 + } 296 + }), 297 + json!({ 298 + "lexicon": 1, 299 + "id": "com.example.nsid", 300 + "defs": { 301 + "main": { 302 + "type": "record", 303 + "record": { 304 + "type": "object", 305 + "properties": { 306 + "nsid": { "type": "string", "format": "nsid" } 307 + } 308 + } 309 + } 310 + } 311 + }), 312 + json!({ 313 + "lexicon": 1, 314 + "id": "com.example.cid", 315 + "defs": { 316 + "main": { 317 + "type": "record", 318 + "record": { 319 + "type": "object", 320 + "properties": { 321 + "cid": { "type": "string", "format": "cid" } 322 + } 323 + } 324 + } 325 + } 326 + }), 327 + json!({ 328 + "lexicon": 1, 329 + "id": "com.example.language", 330 + "defs": { 331 + "main": { 332 + "type": "record", 333 + "record": { 334 + "type": "object", 335 + "properties": { 336 + "language": { "type": "string", "format": "language" } 337 + } 338 + } 339 + } 340 + } 341 + }), 342 + json!({ 343 + "lexicon": 1, 344 + "id": "com.example.byteLength", 345 + "defs": { 346 + "main": { 347 + "type": "record", 348 + "record": { 349 + "type": "object", 350 + "properties": { 351 + "bytes": { 352 + "type": "bytes", 353 + "minLength": 2, 354 + "maxLength": 4 355 + } 356 + } 357 + } 358 + } 359 + } 360 + }), 361 + json!({ 362 + "lexicon": 1, 363 + "id": "com.example.union", 364 + "defs": { 365 + "main": { 366 + "type": "record", 367 + "description": "A record", 368 + "key": "tid", 369 + "record": { 370 + "type": "object", 371 + "required": ["unionOpen", "unionClosed"], 372 + "properties": { 373 + "unionOpen": { 374 + "type": "union", 375 + "refs": [ 376 + "com.example.kitchenSink#object", 377 + "com.example.kitchenSink#subobject" 378 + ] 379 + }, 380 + "unionClosed": { 381 + "type": "union", 382 + "closed": true, 383 + "refs": [ 384 + "com.example.kitchenSink#object", 385 + "com.example.kitchenSink#subobject" 386 + ] 387 + } 388 + } 389 + } 390 + } 391 + } 392 + }), 393 + json!({ 394 + "lexicon": 1, 395 + "id": "com.example.unknown", 396 + "defs": { 397 + "main": { 398 + "type": "record", 399 + "description": "A record", 400 + "key": "tid", 401 + "record": { 402 + "type": "object", 403 + "required": ["unknown"], 404 + "properties": { 405 + "unknown": { "type": "unknown" }, 406 + "optUnknown": { "type": "unknown" } 407 + } 408 + } 409 + } 410 + } 411 + }) 412 + ] 413 + } 414 + 415 + #[test] 416 + fn test_kitchen_sink_validation() { 417 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 418 + 419 + // Valid kitchen sink record (mirroring TypeScript test) 420 + let valid_record = json!({ 421 + "object": { 422 + "object": { "boolean": true }, 423 + "array": ["one", "two"], 424 + "boolean": true, 425 + "integer": 123, 426 + "string": "string" 427 + }, 428 + "array": ["one", "two"], 429 + "boolean": true, 430 + "integer": 123, 431 + "string": "string", 432 + "bytes": "SGVsbG8=", // "Hello" in base64 433 + "cidLink": { 434 + "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" 435 + } 436 + }); 437 + 438 + assert!(validator.validate_record("com.example.kitchenSink", &valid_record).is_ok()); 439 + 440 + // Missing required field (object) 441 + let invalid_record = json!({ 442 + "array": ["one", "two"], 443 + "boolean": true, 444 + "integer": 123, 445 + "string": "string", 446 + "bytes": "SGVsbG8=", 447 + "cidLink": { 448 + "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" 449 + } 450 + }); 451 + 452 + assert!(validator.validate_record("com.example.kitchenSink", &invalid_record).is_err()); 453 + } 454 + 455 + #[test] 456 + fn test_local_refs() { 457 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 458 + 459 + // Valid with local ref (#object -> #subobject) 460 + let valid_record = json!({ 461 + "object": { "boolean": true }, // subobject ref 462 + "array": ["one", "two"], 463 + "boolean": true, 464 + "integer": 123, 465 + "string": "string" 466 + }); 467 + 468 + assert!(validator.validate_record("com.example.kitchenSink#object", &valid_record).is_ok()); 469 + 470 + // Invalid - missing boolean in subobject 471 + let invalid_record = json!({ 472 + "object": {}, // missing required boolean 473 + "array": ["one", "two"], 474 + "boolean": true, 475 + "integer": 123, 476 + "string": "string" 477 + }); 478 + 479 + assert!(validator.validate_record("com.example.kitchenSink#object", &invalid_record).is_err()); 480 + } 481 + 482 + #[test] 483 + fn test_array_length_constraints() { 484 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 485 + 486 + // Valid array length 487 + let valid = json!({ "array": [1, 2, 3] }); 488 + assert!(validator.validate_record("com.example.arrayLength", &valid).is_ok()); 489 + 490 + // Too short 491 + let too_short = json!({ "array": [1] }); 492 + assert!(validator.validate_record("com.example.arrayLength", &too_short).is_err()); 493 + 494 + // Too long 495 + let too_long = json!({ "array": [1, 2, 3, 4, 5] }); 496 + assert!(validator.validate_record("com.example.arrayLength", &too_long).is_err()); 497 + 498 + // Wrong item type 499 + let wrong_type = json!({ "array": [1, "2", 3] }); 500 + assert!(validator.validate_record("com.example.arrayLength", &wrong_type).is_err()); 501 + } 502 + 503 + #[test] 504 + fn test_boolean_const_constraint() { 505 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 506 + 507 + // Valid const 508 + let valid = json!({ "boolean": false }); 509 + assert!(validator.validate_record("com.example.boolConst", &valid).is_ok()); 510 + 511 + // Invalid const 512 + let invalid = json!({ "boolean": true }); 513 + assert!(validator.validate_record("com.example.boolConst", &invalid).is_err()); 514 + } 515 + 516 + #[test] 517 + fn test_integer_constraints() { 518 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 519 + 520 + // Range test - valid 521 + let valid_range = json!({ "integer": 2 }); 522 + assert!(validator.validate_record("com.example.integerRange", &valid_range).is_ok()); 523 + 524 + // Range test - too low 525 + let too_low = json!({ "integer": 1 }); 526 + assert!(validator.validate_record("com.example.integerRange", &too_low).is_err()); 527 + 528 + // Range test - too high 529 + let too_high = json!({ "integer": 5 }); 530 + assert!(validator.validate_record("com.example.integerRange", &too_high).is_err()); 531 + 532 + // Enum test - valid 533 + let valid_enum = json!({ "integer": 2 }); 534 + assert!(validator.validate_record("com.example.integerEnum", &valid_enum).is_ok()); 535 + 536 + // Enum test - invalid 537 + let invalid_enum = json!({ "integer": 0 }); 538 + assert!(validator.validate_record("com.example.integerEnum", &invalid_enum).is_err()); 539 + 540 + // Const test - valid 541 + let valid_const = json!({ "integer": 0 }); 542 + assert!(validator.validate_record("com.example.integerConst", &valid_const).is_ok()); 543 + 544 + // Const test - invalid 545 + let invalid_const = json!({ "integer": 1 }); 546 + assert!(validator.validate_record("com.example.integerConst", &invalid_const).is_err()); 547 + } 548 + 549 + #[test] 550 + fn test_string_constraints() { 551 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 552 + 553 + // Length test - valid 554 + let valid_length = json!({ "string": "ab" }); 555 + assert!(validator.validate_record("com.example.stringLength", &valid_length).is_ok()); 556 + 557 + let valid_length_max = json!({ "string": "abcd" }); 558 + assert!(validator.validate_record("com.example.stringLength", &valid_length_max).is_ok()); 559 + 560 + // Length test - too short 561 + let too_short = json!({ "string": "a" }); 562 + assert!(validator.validate_record("com.example.stringLength", &too_short).is_err()); 563 + 564 + // Length test - too long 565 + let too_long = json!({ "string": "abcde" }); 566 + assert!(validator.validate_record("com.example.stringLength", &too_long).is_err()); 567 + 568 + // Enum test - valid 569 + let valid_enum = json!({ "string": "a" }); 570 + assert!(validator.validate_record("com.example.stringEnum", &valid_enum).is_ok()); 571 + 572 + // Enum test - invalid 573 + let invalid_enum = json!({ "string": "c" }); 574 + assert!(validator.validate_record("com.example.stringEnum", &invalid_enum).is_err()); 575 + 576 + // Const test - valid 577 + let valid_const = json!({ "string": "a" }); 578 + assert!(validator.validate_record("com.example.stringConst", &valid_const).is_ok()); 579 + 580 + // Const test - invalid 581 + let invalid_const = json!({ "string": "b" }); 582 + assert!(validator.validate_record("com.example.stringConst", &invalid_const).is_err()); 583 + } 584 + 585 + #[test] 586 + fn test_string_formats() { 587 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 588 + 589 + // DateTime format tests (matching TypeScript test cases) 590 + let valid_datetimes = vec![ 591 + "2022-12-12T00:50:36.809Z", 592 + "2022-12-12T00:50:36Z", 593 + "2022-12-12T00:50:36.8Z", 594 + "2022-12-12T00:50:36.80Z", 595 + "2022-12-12T00:50:36+00:00", 596 + "2022-12-12T00:50:36.8+00:00", 597 + "2022-12-11T19:50:36-05:00", 598 + "2022-12-11T19:50:36.8-05:00", 599 + "2022-12-11T19:50:36.80-05:00", 600 + "2022-12-11T19:50:36.809-05:00" 601 + ]; 602 + 603 + for datetime in valid_datetimes { 604 + let record = json!({ "datetime": datetime }); 605 + assert!(validator.validate_record("com.example.datetime", &record).is_ok(), 606 + "Should accept datetime: {}", datetime); 607 + } 608 + 609 + let invalid_datetime = json!({ "datetime": "bad date" }); 610 + assert!(validator.validate_record("com.example.datetime", &invalid_datetime).is_err()); 611 + 612 + // URI format tests 613 + let valid_uris = vec![ 614 + "https://example.com", 615 + "https://example.com/with/path", 616 + "https://example.com/with/path?and=query", 617 + "at://bsky.social", 618 + "did:example:test" 619 + ]; 620 + 621 + for uri in valid_uris { 622 + let record = json!({ "uri": uri }); 623 + assert!(validator.validate_record("com.example.uri", &record).is_ok(), 624 + "Should accept URI: {}", uri); 625 + } 626 + 627 + let invalid_uri = json!({ "uri": "not a uri" }); 628 + assert!(validator.validate_record("com.example.uri", &invalid_uri).is_err()); 629 + 630 + // AT-URI format test 631 + let valid_at_uri = json!({ "atUri": "at://did:web:example.com/com.example.test/self" }); 632 + assert!(validator.validate_record("com.example.atUri", &valid_at_uri).is_ok()); 633 + 634 + let invalid_at_uri = json!({ "atUri": "http://not-atproto.com" }); 635 + assert!(validator.validate_record("com.example.atUri", &invalid_at_uri).is_err()); 636 + 637 + // DID format tests 638 + let valid_dids = vec![ 639 + "did:web:example.com", 640 + "did:plc:12345678abcdefghijklmnop" 641 + ]; 642 + 643 + for did in valid_dids { 644 + let record = json!({ "did": did }); 645 + assert!(validator.validate_record("com.example.did", &record).is_ok(), 646 + "Should accept DID: {}", did); 647 + } 648 + 649 + let invalid_dids = vec!["bad did", "did:short"]; 650 + 651 + for did in invalid_dids { 652 + let record = json!({ "did": did }); 653 + assert!(validator.validate_record("com.example.did", &record).is_err(), 654 + "Should reject DID: {}", did); 655 + } 656 + 657 + // Handle format tests 658 + let valid_handles = vec![ 659 + "test.bsky.social", 660 + "bsky.test" 661 + ]; 662 + 663 + for handle in valid_handles { 664 + let record = json!({ "handle": handle }); 665 + assert!(validator.validate_record("com.example.handle", &record).is_ok(), 666 + "Should accept handle: {}", handle); 667 + } 668 + 669 + let invalid_handles = vec!["bad handle", "-bad-.test"]; 670 + 671 + for handle in invalid_handles { 672 + let record = json!({ "handle": handle }); 673 + assert!(validator.validate_record("com.example.handle", &record).is_err(), 674 + "Should reject handle: {}", handle); 675 + } 676 + 677 + // AT-identifier format tests 678 + let valid_at_identifiers = vec![ 679 + "bsky.test", 680 + "did:plc:12345678abcdefghijklmnop" 681 + ]; 682 + 683 + for at_id in valid_at_identifiers { 684 + let record = json!({ "atIdentifier": at_id }); 685 + assert!(validator.validate_record("com.example.atIdentifier", &record).is_ok(), 686 + "Should accept at-identifier: {}", at_id); 687 + } 688 + 689 + let invalid_at_identifiers = vec!["bad id", "-bad-.test"]; 690 + 691 + for at_id in invalid_at_identifiers { 692 + let record = json!({ "atIdentifier": at_id }); 693 + assert!(validator.validate_record("com.example.atIdentifier", &record).is_err(), 694 + "Should reject at-identifier: {}", at_id); 695 + } 696 + 697 + // NSID format tests 698 + let valid_nsids = vec![ 699 + "com.atproto.test", 700 + "app.bsky.nested.test" 701 + ]; 702 + 703 + for nsid in valid_nsids { 704 + let record = json!({ "nsid": nsid }); 705 + assert!(validator.validate_record("com.example.nsid", &record).is_ok(), 706 + "Should accept NSID: {}", nsid); 707 + } 708 + 709 + let invalid_nsids = vec!["bad nsid", "com.bad-.foo"]; 710 + 711 + for nsid in invalid_nsids { 712 + let record = json!({ "nsid": nsid }); 713 + assert!(validator.validate_record("com.example.nsid", &record).is_err(), 714 + "Should reject NSID: {}", nsid); 715 + } 716 + 717 + // CID format test 718 + let valid_cid = json!({ "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" }); 719 + assert!(validator.validate_record("com.example.cid", &valid_cid).is_ok()); 720 + 721 + let invalid_cid = json!({ "cid": "abapsdofiuwrpoiasdfuaspdfoiu" }); 722 + assert!(validator.validate_record("com.example.cid", &invalid_cid).is_err()); 723 + 724 + // Language format test 725 + let valid_language = json!({ "language": "en-US-boont" }); 726 + assert!(validator.validate_record("com.example.language", &valid_language).is_ok()); 727 + 728 + let invalid_language = json!({ "language": "not-a-language-" }); 729 + assert!(validator.validate_record("com.example.language", &invalid_language).is_err()); 730 + } 731 + 732 + #[test] 733 + fn test_bytes_validation() { 734 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 735 + 736 + // Valid bytes (base64, 3 bytes when decoded) 737 + let valid_bytes = json!({ "bytes": "SGVs" }); // 3 bytes when decoded 738 + assert!(validator.validate_record("com.example.byteLength", &valid_bytes).is_ok()); 739 + 740 + // Too short (1 byte when decoded) 741 + let too_short = json!({ "bytes": "SA==" }); 742 + assert!(validator.validate_record("com.example.byteLength", &too_short).is_err()); 743 + 744 + // Too long (5+ bytes when decoded) 745 + let too_long = json!({ "bytes": "SGVsbG9Xb3JsZA==" }); // "HelloWorld" is 10 bytes 746 + assert!(validator.validate_record("com.example.byteLength", &too_long).is_err()); 747 + } 748 + 749 + #[test] 750 + fn test_union_validation() { 751 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 752 + 753 + // Valid union - open union with object variant 754 + let valid_union = json!({ 755 + "unionOpen": { 756 + "$type": "com.example.kitchenSink#object", 757 + "object": { "boolean": true }, 758 + "array": ["one", "two"], 759 + "boolean": true, 760 + "integer": 123, 761 + "string": "string" 762 + }, 763 + "unionClosed": { 764 + "$type": "com.example.kitchenSink#subobject", 765 + "boolean": true 766 + } 767 + }); 768 + 769 + assert!(validator.validate_record("com.example.union", &valid_union).is_ok()); 770 + 771 + // Valid union - open union with other type 772 + let valid_open = json!({ 773 + "unionOpen": { 774 + "$type": "com.example.other" 775 + }, 776 + "unionClosed": { 777 + "$type": "com.example.kitchenSink#subobject", 778 + "boolean": true 779 + } 780 + }); 781 + 782 + // This should work for open unions (they allow unknown types) 783 + assert!(validator.validate_record("com.example.union", &valid_open).is_ok()); 784 + 785 + // Missing $type in union 786 + let missing_type = json!({ 787 + "unionOpen": {}, 788 + "unionClosed": {} 789 + }); 790 + 791 + assert!(validator.validate_record("com.example.union", &missing_type).is_err()); 792 + } 793 + 794 + #[test] 795 + fn test_unknown_type() { 796 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 797 + 798 + // Valid unknown field 799 + let valid_unknown = json!({ "unknown": { "foo": "bar" } }); 800 + assert!(validator.validate_record("com.example.unknown", &valid_unknown).is_ok()); 801 + 802 + // Missing required unknown field 803 + let missing_unknown = json!({}); 804 + assert!(validator.validate_record("com.example.unknown", &missing_unknown).is_err()); 805 + } 806 + 807 + #[test] 808 + fn test_type_validation_errors() { 809 + let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); 810 + 811 + let base_record = json!({ 812 + "object": { 813 + "object": { "boolean": true }, 814 + "array": ["one", "two"], 815 + "boolean": true, 816 + "integer": 123, 817 + "string": "string" 818 + }, 819 + "array": ["one", "two"], 820 + "boolean": true, 821 + "integer": 123, 822 + "string": "string", 823 + "bytes": "SGVsbG8=", 824 + "cidLink": { 825 + "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" 826 + } 827 + }); 828 + 829 + // Wrong boolean type 830 + let mut bad_boolean = base_record.clone(); 831 + bad_boolean["object"]["object"]["boolean"] = json!("not boolean"); 832 + assert!(validator.validate_record("com.example.kitchenSink", &bad_boolean).is_err()); 833 + 834 + // Wrong object type 835 + let mut bad_object = base_record.clone(); 836 + bad_object["object"] = json!(true); 837 + assert!(validator.validate_record("com.example.kitchenSink", &bad_object).is_err()); 838 + 839 + // Wrong array type 840 + let mut bad_array = base_record.clone(); 841 + bad_array["array"] = json!(1234); 842 + assert!(validator.validate_record("com.example.kitchenSink", &bad_array).is_err()); 843 + 844 + // Wrong integer type 845 + let mut bad_integer = base_record.clone(); 846 + bad_integer["integer"] = json!(true); 847 + assert!(validator.validate_record("com.example.kitchenSink", &bad_integer).is_err()); 848 + 849 + // Wrong string type 850 + let mut bad_string = base_record.clone(); 851 + bad_string["string"] = json!({}); 852 + assert!(validator.validate_record("com.example.kitchenSink", &bad_string).is_err()); 853 + 854 + // Wrong bytes type 855 + let mut bad_bytes = base_record.clone(); 856 + bad_bytes["bytes"] = json!(1234); 857 + assert!(validator.validate_record("com.example.kitchenSink", &bad_bytes).is_err()); 858 + 859 + // Wrong CID link type 860 + let mut bad_cid_link = base_record.clone(); 861 + bad_cid_link["cidLink"] = json!("bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"); 862 + assert!(validator.validate_record("com.example.kitchenSink", &bad_cid_link).is_err()); 863 + } 864 + }
+82
api/src/lexicon/types.rs
··· 1 + use serde_json::Value; 2 + 3 + /// Represents a loaded lexicon document 4 + #[derive(Debug, Clone)] 5 + pub struct LexiconDoc { 6 + pub id: String, 7 + pub defs: Value, 8 + } 9 + 10 + #[allow(dead_code)] 11 + impl LexiconDoc { 12 + pub fn id(&self) -> &str { 13 + &self.id 14 + } 15 + } 16 + 17 + /// String format types supported by AT Protocol (per lexicon spec and TS implementation) 18 + #[derive(Debug, Clone, PartialEq)] 19 + pub enum StringFormat { 20 + DateTime, 21 + Uri, 22 + AtUri, 23 + Did, 24 + Handle, 25 + AtIdentifier, // Can be either a Handle or a DID 26 + Nsid, 27 + Cid, 28 + Language, 29 + Tid, 30 + RecordKey, 31 + } 32 + 33 + impl StringFormat { 34 + pub fn from_str(s: &str) -> Option<Self> { 35 + match s { 36 + "datetime" => Some(Self::DateTime), 37 + "uri" => Some(Self::Uri), 38 + "at-uri" => Some(Self::AtUri), 39 + "did" => Some(Self::Did), 40 + "handle" => Some(Self::Handle), 41 + "at-identifier" => Some(Self::AtIdentifier), 42 + "nsid" => Some(Self::Nsid), 43 + "cid" => Some(Self::Cid), 44 + "language" => Some(Self::Language), 45 + "tid" => Some(Self::Tid), 46 + "record-key" => Some(Self::RecordKey), 47 + _ => None, 48 + } 49 + } 50 + } 51 + 52 + /// Validation context to track the current path in the object being validated 53 + #[derive(Debug, Clone)] 54 + pub struct ValidationContext { 55 + pub path: Vec<String>, 56 + } 57 + 58 + impl ValidationContext { 59 + pub fn new() -> Self { 60 + Self { path: Vec::new() } 61 + } 62 + 63 + pub fn with_field(&self, field: &str) -> Self { 64 + let mut path = self.path.clone(); 65 + path.push(field.to_string()); 66 + Self { path } 67 + } 68 + 69 + pub fn with_index(&self, index: usize) -> Self { 70 + let mut path = self.path.clone(); 71 + path.push(format!("[{}]", index)); 72 + Self { path } 73 + } 74 + 75 + pub fn path_string(&self) -> String { 76 + if self.path.is_empty() { 77 + "root".to_string() 78 + } else { 79 + self.path.join(".") 80 + } 81 + } 82 + }
+747
api/src/lexicon/validator.rs
··· 1 + use std::collections::HashMap; 2 + use serde_json::Value; 3 + use chrono::DateTime; 4 + use regex::Regex; 5 + 6 + use crate::database::Database; 7 + use super::errors::ValidationError; 8 + use super::types::{LexiconDoc, StringFormat, ValidationContext}; 9 + 10 + pub struct LexiconValidator { 11 + lexicons: HashMap<String, LexiconDoc>, 12 + } 13 + 14 + impl LexiconValidator { 15 + /// Create a new validator with the given lexicon documents 16 + pub fn new(lexicons: Vec<Value>) -> Result<Self, ValidationError> { 17 + let mut lexicon_map = HashMap::new(); 18 + 19 + for lexicon_value in lexicons { 20 + let id = lexicon_value["id"] 21 + .as_str() 22 + .ok_or_else(|| ValidationError::InvalidSchema("Missing lexicon id".to_string()))? 23 + .to_string(); 24 + 25 + let defs = lexicon_value["defs"].clone(); 26 + if defs.is_null() { 27 + return Err(ValidationError::InvalidSchema(format!("Missing defs in lexicon {}", id))); 28 + } 29 + 30 + lexicon_map.insert(id.clone(), LexiconDoc { id, defs }); 31 + } 32 + 33 + Ok(Self { lexicons: lexicon_map }) 34 + } 35 + 36 + /// Load lexicons for a specific slice from the database 37 + pub async fn for_slice(db: &Database, slice_uri: &str) -> Result<Self, ValidationError> { 38 + let lexicon_records = db.get_lexicons_by_slice(slice_uri) 39 + .await 40 + .map_err(|e| ValidationError::Unknown { 41 + path: "database".to_string(), 42 + message: e.to_string(), 43 + })?; 44 + 45 + // lexicon_records already has the correct format from get_lexicons_by_slice 46 + let lexicons: Vec<serde_json::Value> = lexicon_records 47 + .into_iter() 48 + .map(|record| { 49 + // Add lexicon version field if missing 50 + let mut lexicon = record; 51 + if lexicon.get("lexicon").is_none() { 52 + lexicon["lexicon"] = serde_json::Value::Number(serde_json::Number::from(1)); 53 + } 54 + lexicon 55 + }) 56 + .collect(); 57 + 58 + Self::new(lexicons) 59 + } 60 + 61 + /// Validate a record against its collection's lexicon 62 + pub fn validate_record(&self, collection: &str, record: &Value) -> Result<(), ValidationError> { 63 + // Parse collection string which might have fragment (#object, #main, etc) 64 + let parts: Vec<&str> = collection.split('#').collect(); 65 + let nsid = parts[0]; 66 + let fragment = parts.get(1).map(|s| *s).unwrap_or("main"); 67 + 68 + let lexicon = self.lexicons.get(nsid) 69 + .ok_or_else(|| ValidationError::LexiconNotFound(nsid.to_string()))?; 70 + 71 + // Get the definition schema 72 + let def_schema = &lexicon.defs[fragment]; 73 + if def_schema.is_null() { 74 + return Err(ValidationError::InvalidSchema( 75 + format!("No {} definition in lexicon {}", fragment, nsid) 76 + )); 77 + } 78 + 79 + // For record types, validate against the record schema 80 + let record_schema = if def_schema["type"] == "record" { 81 + &def_schema["record"] 82 + } else { 83 + // For other types like object, validate directly 84 + def_schema 85 + }; 86 + 87 + let ctx = ValidationContext::new(); 88 + self.validate_value(record, record_schema, &ctx) 89 + } 90 + 91 + /// Validate a value against a schema definition 92 + fn validate_value(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 93 + let schema_type = schema["type"].as_str().unwrap_or(""); 94 + 95 + match schema_type { 96 + "object" => self.validate_object(value, schema, ctx), 97 + "string" => self.validate_string(value, schema, ctx), 98 + "integer" => self.validate_integer(value, schema, ctx), 99 + "boolean" => self.validate_boolean(value, schema, ctx), 100 + "array" => self.validate_array(value, schema, ctx), 101 + "ref" => self.validate_ref(value, schema, ctx), 102 + "union" => self.validate_union(value, schema, ctx), 103 + "blob" => self.validate_blob(value, schema, ctx), 104 + "bytes" => self.validate_bytes(value, schema, ctx), 105 + "cid-link" => self.validate_cid_link(value, schema, ctx), 106 + "unknown" => Ok(()), // Unknown type accepts anything 107 + "null" => self.validate_null(value, schema, ctx), 108 + "" => Err(ValidationError::InvalidSchema(format!("Missing type in schema at {}", ctx.path_string()))), 109 + _ => Err(ValidationError::InvalidSchema(format!("Unknown schema type: {} at {}", schema_type, ctx.path_string()))), 110 + } 111 + } 112 + 113 + /// Validate an object against an object schema 114 + fn validate_object(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 115 + let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch { 116 + path: ctx.path_string(), 117 + expected: "object".to_string(), 118 + actual: format!("{:?}", value), 119 + })?; 120 + 121 + // Check required fields 122 + if let Some(required) = schema["required"].as_array() { 123 + for req_field in required { 124 + if let Some(field_name) = req_field.as_str() { 125 + if !obj.contains_key(field_name) { 126 + return Err(ValidationError::RequiredFieldMissing { 127 + path: ctx.with_field(field_name).path_string(), 128 + }); 129 + } 130 + } 131 + } 132 + } 133 + 134 + // Validate properties 135 + if let Some(properties) = schema["properties"].as_object() { 136 + for (prop_name, prop_schema) in properties { 137 + if let Some(prop_value) = obj.get(prop_name) { 138 + // Check if field can be null 139 + let nullable = if let Some(nullable_fields) = schema["nullable"].as_array() { 140 + nullable_fields.iter().any(|f| f.as_str() == Some(prop_name)) 141 + } else { 142 + false 143 + }; 144 + 145 + if prop_value.is_null() && !nullable { 146 + return Err(ValidationError::TypeMismatch { 147 + path: ctx.with_field(prop_name).path_string(), 148 + expected: "non-null".to_string(), 149 + actual: "null".to_string(), 150 + }); 151 + } 152 + 153 + if !prop_value.is_null() { 154 + self.validate_value(prop_value, prop_schema, &ctx.with_field(prop_name))?; 155 + } 156 + } 157 + } 158 + } 159 + 160 + Ok(()) 161 + } 162 + 163 + /// Validate a string value 164 + fn validate_string(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 165 + let string_val = value.as_str().ok_or_else(|| ValidationError::TypeMismatch { 166 + path: ctx.path_string(), 167 + expected: "string".to_string(), 168 + actual: format!("{:?}", value), 169 + })?; 170 + 171 + // Check min/max length (in UTF-8 bytes) 172 + if let Some(min_length) = schema["minLength"].as_u64() { 173 + if (string_val.len() as u64) < min_length { 174 + return Err(ValidationError::StringValidationFailed { 175 + path: ctx.path_string(), 176 + message: format!("String length {} is less than minimum {}", string_val.len(), min_length), 177 + }); 178 + } 179 + } 180 + 181 + if let Some(max_length) = schema["maxLength"].as_u64() { 182 + if (string_val.len() as u64) > max_length { 183 + return Err(ValidationError::StringValidationFailed { 184 + path: ctx.path_string(), 185 + message: format!("String length {} exceeds maximum {}", string_val.len(), max_length), 186 + }); 187 + } 188 + } 189 + 190 + // Check min/max graphemes (simplified grapheme counting) 191 + if let Some(min_graphemes) = schema["minGraphemes"].as_u64() { 192 + let grapheme_count = self.count_graphemes(string_val); 193 + if (grapheme_count as u64) < min_graphemes { 194 + return Err(ValidationError::StringValidationFailed { 195 + path: ctx.path_string(), 196 + message: format!("String has {} graphemes, less than minimum {}", grapheme_count, min_graphemes), 197 + }); 198 + } 199 + } 200 + 201 + if let Some(max_graphemes) = schema["maxGraphemes"].as_u64() { 202 + let grapheme_count = self.count_graphemes(string_val); 203 + if (grapheme_count as u64) > max_graphemes { 204 + return Err(ValidationError::StringValidationFailed { 205 + path: ctx.path_string(), 206 + message: format!("String has {} graphemes, exceeds maximum {}", grapheme_count, max_graphemes), 207 + }); 208 + } 209 + } 210 + 211 + // Check const value 212 + if let Some(const_val) = schema["const"].as_str() { 213 + if string_val != const_val { 214 + return Err(ValidationError::TypeMismatch { 215 + path: ctx.path_string(), 216 + expected: format!("\"{}\"", const_val), 217 + actual: format!("\"{}\"", string_val), 218 + }); 219 + } 220 + } 221 + 222 + // Check enum values 223 + if let Some(enum_values) = schema["enum"].as_array() { 224 + let valid = enum_values.iter().any(|v| v.as_str() == Some(string_val)); 225 + if !valid { 226 + return Err(ValidationError::EnumValidationFailed { 227 + path: ctx.path_string(), 228 + }); 229 + } 230 + } 231 + 232 + // Check format 233 + if let Some(format_str) = schema["format"].as_str() { 234 + if let Some(format) = StringFormat::from_str(format_str) { 235 + self.validate_string_format(string_val, format, ctx)?; 236 + } 237 + } 238 + 239 + Ok(()) 240 + } 241 + 242 + /// Validate string formats 243 + fn validate_string_format(&self, value: &str, format: StringFormat, ctx: &ValidationContext) -> Result<(), ValidationError> { 244 + match format { 245 + StringFormat::DateTime => { 246 + // Validate RFC3339/ISO8601 datetime 247 + DateTime::parse_from_rfc3339(value).map_err(|_| ValidationError::FormatValidationFailed { 248 + path: ctx.path_string(), 249 + format: "datetime".to_string(), 250 + })?; 251 + }, 252 + StringFormat::Uri => { 253 + // Basic URI validation - must have scheme followed by colon 254 + // Valid schemes include http://, https://, urn:, did:, etc. 255 + if !value.contains(':') || value.starts_with(':') { 256 + return Err(ValidationError::FormatValidationFailed { 257 + path: ctx.path_string(), 258 + format: "uri".to_string(), 259 + }); 260 + } 261 + 262 + // Check that scheme contains only valid characters 263 + let colon_pos = value.find(':').unwrap(); 264 + let scheme = &value[..colon_pos]; 265 + if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') { 266 + return Err(ValidationError::FormatValidationFailed { 267 + path: ctx.path_string(), 268 + format: "uri".to_string(), 269 + }); 270 + } 271 + }, 272 + StringFormat::AtUri => { 273 + // AT-URI format: at://[authority]/[collection]/[rkey] 274 + if !value.starts_with("at://") { 275 + return Err(ValidationError::FormatValidationFailed { 276 + path: ctx.path_string(), 277 + format: "at-uri".to_string(), 278 + }); 279 + } 280 + }, 281 + StringFormat::Did => { 282 + // DID format: did:method:identifier 283 + if !value.starts_with("did:") { 284 + return Err(ValidationError::FormatValidationFailed { 285 + path: ctx.path_string(), 286 + format: "did".to_string(), 287 + }); 288 + } 289 + 290 + // Must have at least 3 parts: did:method:identifier 291 + let parts: Vec<&str> = value.split(':').collect(); 292 + if parts.len() < 3 || parts[0] != "did" || parts[1].is_empty() || parts[2].is_empty() { 293 + return Err(ValidationError::FormatValidationFailed { 294 + path: ctx.path_string(), 295 + format: "did".to_string(), 296 + }); 297 + } 298 + }, 299 + StringFormat::Handle => { 300 + // Handle format: domain-like (e.g., user.bsky.social) 301 + let handle_regex = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap(); 302 + if !handle_regex.is_match(value) { 303 + return Err(ValidationError::FormatValidationFailed { 304 + path: ctx.path_string(), 305 + format: "handle".to_string(), 306 + }); 307 + } 308 + }, 309 + StringFormat::AtIdentifier => { 310 + // Either a DID or a handle (per spec: at-identifier can be either) 311 + let is_did = value.starts_with("did:"); 312 + 313 + // Handle format: domain-like (e.g., user.bsky.social) 314 + let handle_regex = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap(); 315 + let is_handle = handle_regex.is_match(value); 316 + 317 + if !is_did && !is_handle { 318 + return Err(ValidationError::FormatValidationFailed { 319 + path: ctx.path_string(), 320 + format: "at-identifier".to_string(), 321 + }); 322 + } 323 + }, 324 + StringFormat::Nsid => { 325 + // NSID format: reversed domain with name (e.g., com.example.foo) 326 + let nsid_regex = Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$").unwrap(); 327 + if !nsid_regex.is_match(value) { 328 + return Err(ValidationError::FormatValidationFailed { 329 + path: ctx.path_string(), 330 + format: "nsid".to_string(), 331 + }); 332 + } 333 + }, 334 + StringFormat::Cid => { 335 + // Basic CID validation (starts with correct multibase prefix) 336 + if !value.starts_with("bafy") && !value.starts_with("bafk") && !value.starts_with("b") { 337 + return Err(ValidationError::FormatValidationFailed { 338 + path: ctx.path_string(), 339 + format: "cid".to_string(), 340 + }); 341 + } 342 + }, 343 + StringFormat::Tid => { 344 + // TID format: timestamp-based identifier (13 chars, base32) 345 + let tid_regex = Regex::new(r"^[234567abcdefghijklmnopqrstuvwxyz]{13}$").unwrap(); 346 + if !tid_regex.is_match(value) { 347 + return Err(ValidationError::FormatValidationFailed { 348 + path: ctx.path_string(), 349 + format: "tid".to_string(), 350 + }); 351 + } 352 + }, 353 + StringFormat::RecordKey => { 354 + // Record key: alphanumeric, dash, underscore, colon, tilde, or TID 355 + let rkey_regex = Regex::new(r"^[a-zA-Z0-9._:~-]+$").unwrap(); 356 + if !rkey_regex.is_match(value) || value.len() > 512 { 357 + return Err(ValidationError::FormatValidationFailed { 358 + path: ctx.path_string(), 359 + format: "record-key".to_string(), 360 + }); 361 + } 362 + }, 363 + StringFormat::Language => { 364 + // BCP47 language tag (simplified validation) 365 + // Allows for language-region-extension pattern like "en-US-boont" 366 + let lang_regex = Regex::new(r"^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*$").unwrap(); 367 + if !lang_regex.is_match(value) { 368 + return Err(ValidationError::FormatValidationFailed { 369 + path: ctx.path_string(), 370 + format: "language".to_string(), 371 + }); 372 + } 373 + }, 374 + } 375 + 376 + Ok(()) 377 + } 378 + 379 + /// Validate an integer value 380 + fn validate_integer(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 381 + let int_val = value.as_i64().ok_or_else(|| ValidationError::TypeMismatch { 382 + path: ctx.path_string(), 383 + expected: "integer".to_string(), 384 + actual: format!("{:?}", value), 385 + })?; 386 + 387 + if let Some(minimum) = schema["minimum"].as_i64() { 388 + if int_val < minimum { 389 + return Err(ValidationError::IntegerValidationFailed { 390 + path: ctx.path_string(), 391 + message: format!("Value {} is less than minimum {}", int_val, minimum), 392 + }); 393 + } 394 + } 395 + 396 + if let Some(maximum) = schema["maximum"].as_i64() { 397 + if int_val > maximum { 398 + return Err(ValidationError::IntegerValidationFailed { 399 + path: ctx.path_string(), 400 + message: format!("Value {} exceeds maximum {}", int_val, maximum), 401 + }); 402 + } 403 + } 404 + 405 + // Check const value 406 + if let Some(const_val) = schema["const"].as_i64() { 407 + if int_val != const_val { 408 + return Err(ValidationError::TypeMismatch { 409 + path: ctx.path_string(), 410 + expected: format!("{}", const_val), 411 + actual: format!("{}", int_val), 412 + }); 413 + } 414 + } 415 + 416 + if let Some(enum_values) = schema["enum"].as_array() { 417 + let valid = enum_values.iter().any(|v| v.as_i64() == Some(int_val)); 418 + if !valid { 419 + return Err(ValidationError::EnumValidationFailed { 420 + path: ctx.path_string(), 421 + }); 422 + } 423 + } 424 + 425 + Ok(()) 426 + } 427 + 428 + /// Validate a boolean value 429 + fn validate_boolean(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 430 + value.as_bool().ok_or_else(|| ValidationError::TypeMismatch { 431 + path: ctx.path_string(), 432 + expected: "boolean".to_string(), 433 + actual: format!("{:?}", value), 434 + })?; 435 + 436 + // Check const value if specified 437 + if let Some(const_val) = schema["const"].as_bool() { 438 + if value.as_bool() != Some(const_val) { 439 + return Err(ValidationError::TypeMismatch { 440 + path: ctx.path_string(), 441 + expected: format!("{}", const_val), 442 + actual: format!("{:?}", value), 443 + }); 444 + } 445 + } 446 + 447 + Ok(()) 448 + } 449 + 450 + /// Validate an array 451 + fn validate_array(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 452 + let array = value.as_array().ok_or_else(|| ValidationError::TypeMismatch { 453 + path: ctx.path_string(), 454 + expected: "array".to_string(), 455 + actual: format!("{:?}", value), 456 + })?; 457 + 458 + // Check min/max length 459 + if let Some(min_length) = schema["minLength"].as_u64() { 460 + if (array.len() as u64) < min_length { 461 + return Err(ValidationError::ArrayValidationFailed { 462 + path: ctx.path_string(), 463 + message: format!("Array length {} is less than minimum {}", array.len(), min_length), 464 + }); 465 + } 466 + } 467 + 468 + if let Some(max_length) = schema["maxLength"].as_u64() { 469 + if (array.len() as u64) > max_length { 470 + return Err(ValidationError::ArrayValidationFailed { 471 + path: ctx.path_string(), 472 + message: format!("Array length {} exceeds maximum {}", array.len(), max_length), 473 + }); 474 + } 475 + } 476 + 477 + // Validate items 478 + if let Some(items_schema) = schema.get("items") { 479 + for (i, item) in array.iter().enumerate() { 480 + self.validate_value(item, items_schema, &ctx.with_index(i))?; 481 + } 482 + } 483 + 484 + Ok(()) 485 + } 486 + 487 + /// Validate a reference 488 + fn validate_ref(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 489 + let ref_path = schema["ref"].as_str().ok_or_else(|| { 490 + ValidationError::InvalidSchema(format!("Missing ref path at {}", ctx.path_string())) 491 + })?; 492 + 493 + // Resolve the reference 494 + let resolved_schema = self.resolve_ref(ref_path)?; 495 + 496 + // Validate against the resolved schema 497 + self.validate_value(value, &resolved_schema, ctx) 498 + } 499 + 500 + /// Resolve a reference to its schema 501 + fn resolve_ref(&self, ref_path: &str) -> Result<Value, ValidationError> { 502 + // Note: Local references need context of which lexicon we're in 503 + // For now, we'll need to pass the current lexicon context 504 + // This is a limitation of the current design 505 + if ref_path.starts_with('#') { 506 + // Local reference - would need current lexicon context to resolve 507 + // For comprehensive testing, we'll implement a basic version 508 + let fragment = &ref_path[1..]; // Remove the # prefix 509 + 510 + // Try to find this fragment in any loaded lexicon (simplified approach) 511 + for lexicon in self.lexicons.values() { 512 + let def = &lexicon.defs[fragment]; 513 + if !def.is_null() { 514 + return Ok(def.clone()); 515 + } 516 + } 517 + 518 + return Err(ValidationError::ReferenceNotFound(ref_path.to_string())); 519 + } 520 + 521 + // Parse NSID#fragment format 522 + let parts: Vec<&str> = ref_path.split('#').collect(); 523 + let nsid = parts[0]; 524 + let fragment = parts.get(1).map(|s| *s).unwrap_or("main"); 525 + 526 + let lexicon = self.lexicons.get(nsid) 527 + .ok_or_else(|| ValidationError::ReferenceNotFound(nsid.to_string()))?; 528 + 529 + let def = &lexicon.defs[fragment]; 530 + if def.is_null() { 531 + return Err(ValidationError::ReferenceNotFound(ref_path.to_string())); 532 + } 533 + 534 + Ok(def.clone()) 535 + } 536 + 537 + /// Validate a union type 538 + fn validate_union(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 539 + let refs = schema["refs"].as_array().ok_or_else(|| { 540 + ValidationError::InvalidSchema(format!("Union missing refs at {}", ctx.path_string())) 541 + })?; 542 + 543 + let is_closed = schema["closed"].as_bool().unwrap_or(false); 544 + 545 + // Check if value has $type field 546 + if let Some(type_field) = value.get("$type").and_then(|v| v.as_str()) { 547 + // Try to match against the specific type 548 + for ref_value in refs { 549 + if let Some(ref_str) = ref_value.as_str() { 550 + // For exact type match, validate against the reference 551 + if ref_str == type_field || ref_str.split('#').next().unwrap_or(ref_str) == type_field { 552 + return self.validate_ref(value, &serde_json::json!({"ref": ref_str}), ctx); 553 + } 554 + } 555 + } 556 + 557 + // If this is an open union and we have a $type field, allow unknown types 558 + if !is_closed { 559 + // For open unions, any object with a $type is valid as long as it's structured properly 560 + if value.is_object() { 561 + return Ok(()); 562 + } 563 + } 564 + } 565 + 566 + // Try each variant (fallback for objects without $type or when $type doesn't match) 567 + for ref_value in refs { 568 + if let Some(ref_str) = ref_value.as_str() { 569 + let result = self.validate_ref(value, &serde_json::json!({"ref": ref_str}), ctx); 570 + if result.is_ok() { 571 + return Ok(()); 572 + } 573 + } 574 + } 575 + 576 + Err(ValidationError::UnionValidationFailed { 577 + path: ctx.path_string(), 578 + }) 579 + } 580 + 581 + /// Validate a blob reference 582 + fn validate_blob(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 583 + let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch { 584 + path: ctx.path_string(), 585 + expected: "blob object".to_string(), 586 + actual: format!("{:?}", value), 587 + })?; 588 + 589 + // Check required blob fields 590 + if !obj.contains_key("$type") || obj["$type"] != "blob" { 591 + return Err(ValidationError::TypeMismatch { 592 + path: ctx.path_string(), 593 + expected: "blob with $type".to_string(), 594 + actual: format!("{:?}", value), 595 + }); 596 + } 597 + 598 + // Check for ref object with $link 599 + let ref_obj = obj.get("ref").and_then(|v| v.as_object()).ok_or_else(|| { 600 + ValidationError::TypeMismatch { 601 + path: ctx.with_field("ref").path_string(), 602 + expected: "ref object".to_string(), 603 + actual: "missing or invalid".to_string(), 604 + } 605 + })?; 606 + 607 + if !ref_obj.contains_key("$link") { 608 + return Err(ValidationError::TypeMismatch { 609 + path: ctx.with_field("ref.$link").path_string(), 610 + expected: "CID link".to_string(), 611 + actual: "missing".to_string(), 612 + }); 613 + } 614 + 615 + // Check other required fields 616 + if !obj.contains_key("mimeType") { 617 + return Err(ValidationError::RequiredFieldMissing { 618 + path: ctx.with_field("mimeType").path_string(), 619 + }); 620 + } 621 + 622 + if !obj.contains_key("size") { 623 + return Err(ValidationError::RequiredFieldMissing { 624 + path: ctx.with_field("size").path_string(), 625 + }); 626 + } 627 + 628 + Ok(()) 629 + } 630 + 631 + /// Validate bytes type 632 + fn validate_bytes(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 633 + // Bytes in JSON are typically base64 encoded strings 634 + let string_val = value.as_str().ok_or_else(|| ValidationError::TypeMismatch { 635 + path: ctx.path_string(), 636 + expected: "bytes (base64 string)".to_string(), 637 + actual: format!("{:?}", value), 638 + })?; 639 + 640 + // Basic base64 validation (simplified) 641 + if !string_val.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') { 642 + return Err(ValidationError::TypeMismatch { 643 + path: ctx.path_string(), 644 + expected: "valid base64 string".to_string(), 645 + actual: "invalid base64".to_string(), 646 + }); 647 + } 648 + 649 + // Check length constraints (bytes length, not string length) 650 + let decoded_len = (string_val.len() * 3 / 4) - string_val.chars().filter(|&c| c == '=').count(); 651 + 652 + if let Some(min_length) = schema["minLength"].as_u64() { 653 + if (decoded_len as u64) < min_length { 654 + return Err(ValidationError::StringValidationFailed { 655 + path: ctx.path_string(), 656 + message: format!("Bytes length {} is less than minimum {}", decoded_len, min_length), 657 + }); 658 + } 659 + } 660 + 661 + if let Some(max_length) = schema["maxLength"].as_u64() { 662 + if (decoded_len as u64) > max_length { 663 + return Err(ValidationError::StringValidationFailed { 664 + path: ctx.path_string(), 665 + message: format!("Bytes length {} exceeds maximum {}", decoded_len, max_length), 666 + }); 667 + } 668 + } 669 + 670 + Ok(()) 671 + } 672 + 673 + /// Validate cid-link type 674 + fn validate_cid_link(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 675 + let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch { 676 + path: ctx.path_string(), 677 + expected: "cid-link object".to_string(), 678 + actual: format!("{:?}", value), 679 + })?; 680 + 681 + // CID-link must have $link field with a valid CID 682 + let link = obj.get("$link").and_then(|v| v.as_str()).ok_or_else(|| { 683 + ValidationError::RequiredFieldMissing { 684 + path: ctx.with_field("$link").path_string(), 685 + } 686 + })?; 687 + 688 + // Basic CID validation (simplified - should start with appropriate multibase prefix) 689 + if !link.starts_with("bafy") && !link.starts_with("bafk") && !link.starts_with("b") && link.len() < 20 { 690 + return Err(ValidationError::FormatValidationFailed { 691 + path: ctx.with_field("$link").path_string(), 692 + format: "cid".to_string(), 693 + }); 694 + } 695 + 696 + Ok(()) 697 + } 698 + 699 + /// Validate null type 700 + fn validate_null(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { 701 + if !value.is_null() { 702 + return Err(ValidationError::TypeMismatch { 703 + path: ctx.path_string(), 704 + expected: "null".to_string(), 705 + actual: format!("{:?}", value), 706 + }); 707 + } 708 + Ok(()) 709 + } 710 + 711 + /// Simplified grapheme counting (approximation) 712 + /// This is a basic implementation - for full compliance with TS version, 713 + /// would need proper Unicode grapheme cluster segmentation 714 + fn count_graphemes(&self, s: &str) -> usize { 715 + // Very simplified approach: count Unicode scalar values, with basic combining character handling 716 + let mut count = 0; 717 + let mut chars = s.chars().peekable(); 718 + 719 + while let Some(_ch) = chars.next() { 720 + count += 1; 721 + 722 + // Skip combining characters that follow this base character 723 + while let Some(&next_ch) = chars.peek() { 724 + if self.is_combining_character(next_ch) { 725 + chars.next(); // consume the combining character 726 + } else { 727 + break; 728 + } 729 + } 730 + } 731 + 732 + count 733 + } 734 + 735 + /// Check if a character is a combining character (very simplified) 736 + fn is_combining_character(&self, ch: char) -> bool { 737 + // This is a simplified check for common combining marks 738 + // In a full implementation, would need proper Unicode category checking 739 + matches!(ch as u32, 740 + 0x0300..=0x036F | // Combining Diacritical Marks 741 + 0x1AB0..=0x1AFF | // Combining Diacritical Marks Extended 742 + 0x1DC0..=0x1DFF | // Combining Diacritical Marks Supplement 743 + 0x20D0..=0x20FF | // Combining Diacritical Marks for Symbols 744 + 0xFE20..=0xFE2F // Combining Half Marks 745 + ) 746 + } 747 + }
+1
api/src/main.rs
··· 12 12 mod handler_xrpc_codegen; 13 13 mod handler_xrpc_dynamic; 14 14 mod jobs; 15 + mod lexicon; 15 16 mod models; 16 17 mod sync; 17 18
+41 -20
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-28 20:17:41 UTC 2 + // Generated at: 2025-08-29 00:33:57 UTC 3 3 // Lexicons: 3 4 4 5 5 /** ··· 25 25 * ``` 26 26 */ 27 27 28 - import { OAuthClient } from "@slices/oauth"; 28 + import { OAuthClient } from "jsr:@slices/oauth@^0.3.2"; 29 29 30 30 export interface RecordResponse<T> { 31 31 uri: string; ··· 415 415 record: SocialSlicesSliceRecord, 416 416 useSelfRkey?: boolean 417 417 ): Promise<{ uri: string; cid: string }> { 418 - const recordWithType = { $type: "social.slices.slice", ...record }; 419 - const payload = useSelfRkey 420 - ? { ...recordWithType, rkey: "self" } 421 - : recordWithType; 418 + const recordValue = { $type: "social.slices.slice", ...record }; 419 + const payload = { 420 + slice: this.sliceUri, 421 + ...(useSelfRkey ? { rkey: "self" } : {}), 422 + record: recordValue, 423 + }; 422 424 return await this.makeRequest<{ uri: string; cid: string }>( 423 425 "social.slices.slice.createRecord", 424 426 "POST", ··· 430 432 rkey: string, 431 433 record: SocialSlicesSliceRecord 432 434 ): Promise<{ uri: string; cid: string }> { 433 - const recordWithType = { $type: "social.slices.slice", ...record }; 435 + const recordValue = { $type: "social.slices.slice", ...record }; 436 + const payload = { 437 + slice: this.sliceUri, 438 + rkey, 439 + record: recordValue, 440 + }; 434 441 return await this.makeRequest<{ uri: string; cid: string }>( 435 442 "social.slices.slice.updateRecord", 436 443 "POST", 437 - { rkey, record: recordWithType } 444 + payload 438 445 ); 439 446 } 440 447 ··· 548 555 record: SocialSlicesLexiconRecord, 549 556 useSelfRkey?: boolean 550 557 ): Promise<{ uri: string; cid: string }> { 551 - const recordWithType = { $type: "social.slices.lexicon", ...record }; 552 - const payload = useSelfRkey 553 - ? { ...recordWithType, rkey: "self" } 554 - : recordWithType; 558 + const recordValue = { $type: "social.slices.lexicon", ...record }; 559 + const payload = { 560 + slice: this.sliceUri, 561 + ...(useSelfRkey ? { rkey: "self" } : {}), 562 + record: recordValue, 563 + }; 555 564 return await this.makeRequest<{ uri: string; cid: string }>( 556 565 "social.slices.lexicon.createRecord", 557 566 "POST", ··· 563 572 rkey: string, 564 573 record: SocialSlicesLexiconRecord 565 574 ): Promise<{ uri: string; cid: string }> { 566 - const recordWithType = { $type: "social.slices.lexicon", ...record }; 575 + const recordValue = { $type: "social.slices.lexicon", ...record }; 576 + const payload = { 577 + slice: this.sliceUri, 578 + rkey, 579 + record: recordValue, 580 + }; 567 581 return await this.makeRequest<{ uri: string; cid: string }>( 568 582 "social.slices.lexicon.updateRecord", 569 583 "POST", 570 - { rkey, record: recordWithType } 584 + payload 571 585 ); 572 586 } 573 587 ··· 619 633 record: SocialSlicesActorProfileRecord, 620 634 useSelfRkey?: boolean 621 635 ): Promise<{ uri: string; cid: string }> { 622 - const recordWithType = { $type: "social.slices.actor.profile", ...record }; 623 - const payload = useSelfRkey 624 - ? { ...recordWithType, rkey: "self" } 625 - : recordWithType; 636 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 637 + const payload = { 638 + slice: this.sliceUri, 639 + ...(useSelfRkey ? { rkey: "self" } : {}), 640 + record: recordValue, 641 + }; 626 642 return await this.makeRequest<{ uri: string; cid: string }>( 627 643 "social.slices.actor.profile.createRecord", 628 644 "POST", ··· 634 650 rkey: string, 635 651 record: SocialSlicesActorProfileRecord 636 652 ): Promise<{ uri: string; cid: string }> { 637 - const recordWithType = { $type: "social.slices.actor.profile", ...record }; 653 + const recordValue = { $type: "social.slices.actor.profile", ...record }; 654 + const payload = { 655 + slice: this.sliceUri, 656 + rkey, 657 + record: recordValue, 658 + }; 638 659 return await this.makeRequest<{ uri: string; cid: string }>( 639 660 "social.slices.actor.profile.updateRecord", 640 661 "POST", 641 - { rkey, record: recordWithType } 662 + payload 642 663 ); 643 664 } 644 665
+7 -5
frontend/src/routes/slices.tsx
··· 307 307 slice: sliceUri, 308 308 }; 309 309 310 - const result = await atprotoClient.social.slices.lexicon.createRecord( 310 + // Use slice-specific client for creating lexicon 311 + const sliceClient = getSliceClient(context, sliceId); 312 + const result = await sliceClient.social.slices.lexicon.createRecord( 311 313 lexiconRecord 312 314 ); 313 315 ··· 405 407 } 406 408 407 409 try { 408 - // Delete the lexicon record from AT Protocol 409 - await atprotoClient.social.slices.lexicon.deleteRecord(rkey); 410 - 411 - // Check if there are any remaining lexicons using slice-specific client 410 + // Use slice-specific client for deleting lexicon 412 411 const sliceClient = getSliceClient(context, sliceId); 412 + await sliceClient.social.slices.lexicon.deleteRecord(rkey); 413 + 414 + // Check if there are any remaining lexicons 413 415 const remainingLexicons = 414 416 await sliceClient.social.slices.lexicon.listRecords(); 415 417