Highly ambitious ATProtocol AppView service and sdks

add xrpc for open api generation for slices, add a new page to frontend to render the open api spec and interact with the api with Scalar

+1276 -1
+10
api/.spectral.yaml
··· 1 + # Spectral linting for OpenAPI specs 2 + # 3 + # Usage: 4 + # spectral lint <path-to-openapi-spec> 5 + # spectral lint openapi.json --ruleset .spectral.yaml 6 + # 7 + # To lint the generated OpenAPI spec from the API: 8 + # curl "http://localhost:3000/xrpc/social.slices.slice.openapi?slice=at://did:plc:example/social.slices.slice/example" | spectral lint - 9 + # 10 + extends: ["spectral:oas"]
+985
api/src/handler_openapi_spec.rs
··· 1 + use axum::{ 2 + extract::{Query, State}, 3 + http::StatusCode, 4 + response::Json, 5 + }; 6 + use serde::{Deserialize, Serialize}; 7 + use std::collections::HashMap; 8 + 9 + use crate::AppState; 10 + 11 + #[derive(Deserialize)] 12 + pub struct OpenApiParams { 13 + pub slice: String, 14 + } 15 + 16 + #[derive(Serialize, Clone)] 17 + pub struct OpenApiSpec { 18 + openapi: String, 19 + info: OpenApiInfo, 20 + servers: Vec<OpenApiServer>, 21 + paths: HashMap<String, HashMap<String, OpenApiOperation>>, 22 + components: OpenApiComponents, 23 + } 24 + 25 + #[derive(Serialize, Clone)] 26 + pub struct OpenApiInfo { 27 + title: String, 28 + version: String, 29 + description: String, 30 + contact: OpenApiContact, 31 + } 32 + 33 + #[derive(Serialize, Clone)] 34 + pub struct OpenApiContact { 35 + name: String, 36 + url: String, 37 + } 38 + 39 + #[derive(Serialize, Clone)] 40 + pub struct OpenApiServer { 41 + url: String, 42 + description: String, 43 + } 44 + 45 + #[derive(Serialize, Clone)] 46 + pub struct OpenApiOperation { 47 + #[serde(rename = "operationId")] 48 + operation_id: String, 49 + summary: String, 50 + description: String, 51 + #[serde(skip_serializing_if = "Option::is_none")] 52 + parameters: Option<Vec<OpenApiParameter>>, 53 + #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")] 54 + request_body: Option<OpenApiRequestBody>, 55 + responses: HashMap<String, OpenApiResponse>, 56 + tags: Vec<String>, 57 + #[serde(skip_serializing_if = "Option::is_none")] 58 + security: Option<Vec<HashMap<String, Vec<String>>>>, 59 + } 60 + 61 + #[derive(Serialize, Clone)] 62 + pub struct OpenApiParameter { 63 + name: String, 64 + #[serde(rename = "in")] 65 + location: String, 66 + description: String, 67 + required: bool, 68 + schema: OpenApiSchema, 69 + #[serde(skip_serializing_if = "Option::is_none")] 70 + example: Option<String>, 71 + } 72 + 73 + #[derive(Serialize, Clone)] 74 + pub struct OpenApiRequestBody { 75 + description: String, 76 + content: HashMap<String, OpenApiMediaType>, 77 + required: bool, 78 + } 79 + 80 + #[derive(Serialize, Clone)] 81 + pub struct OpenApiMediaType { 82 + schema: OpenApiSchema, 83 + } 84 + 85 + #[derive(Serialize, Clone)] 86 + pub struct OpenApiResponse { 87 + description: String, 88 + #[serde(skip_serializing_if = "Option::is_none")] 89 + content: Option<HashMap<String, OpenApiMediaType>>, 90 + } 91 + 92 + #[derive(Serialize, Clone)] 93 + pub struct OpenApiSchema { 94 + #[serde(rename = "type")] 95 + schema_type: String, 96 + #[serde(skip_serializing_if = "Option::is_none")] 97 + format: Option<String>, 98 + #[serde(skip_serializing_if = "Option::is_none")] 99 + items: Option<Box<OpenApiSchema>>, 100 + #[serde(skip_serializing_if = "Option::is_none")] 101 + properties: Option<HashMap<String, OpenApiSchema>>, 102 + #[serde(skip_serializing_if = "Option::is_none")] 103 + required: Option<Vec<String>>, 104 + #[serde(skip_serializing_if = "Option::is_none")] 105 + default: Option<serde_json::Value>, 106 + } 107 + 108 + #[derive(Serialize, Clone)] 109 + pub struct OpenApiSecurityScheme { 110 + #[serde(rename = "type")] 111 + scheme_type: String, 112 + scheme: String, 113 + #[serde(rename = "bearerFormat", skip_serializing_if = "Option::is_none")] 114 + bearer_format: Option<String>, 115 + description: String, 116 + #[serde(skip_serializing_if = "Option::is_none")] 117 + example: Option<String>, 118 + } 119 + 120 + #[derive(Serialize, Clone)] 121 + pub struct OpenApiComponents { 122 + schemas: HashMap<String, OpenApiSchema>, 123 + #[serde(rename = "securitySchemes", skip_serializing_if = "Option::is_none")] 124 + security_schemes: Option<HashMap<String, OpenApiSecurityScheme>>, 125 + } 126 + 127 + pub async fn get_openapi_spec( 128 + State(state): State<AppState>, 129 + Query(params): Query<OpenApiParams>, 130 + ) -> Result<Json<OpenApiSpec>, StatusCode> { 131 + // Get collections for this slice 132 + let slice_collections = state 133 + .database 134 + .get_slice_collections_list(&params.slice) 135 + .await 136 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 137 + 138 + if slice_collections.is_empty() { 139 + return Err(StatusCode::NOT_FOUND); 140 + } 141 + 142 + // Get lexicon definitions for this slice 143 + let mut collection_lexicons = HashMap::new(); 144 + if let Ok(lexicons) = state.database.get_lexicons_by_slice(&params.slice).await { 145 + for lexicon in lexicons { 146 + if let Some(nsid) = lexicon.get("nsid").and_then(|v| v.as_str()) { 147 + collection_lexicons.insert(nsid.to_string(), lexicon); 148 + } 149 + } 150 + } 151 + 152 + let mut paths = HashMap::new(); 153 + let mut schemas = HashMap::new(); 154 + 155 + // Create OpenAPI paths for each collection 156 + for collection in slice_collections { 157 + let lexicon_data = collection_lexicons.get(&collection); 158 + create_collection_paths(&collection, &params.slice, lexicon_data, &mut paths); 159 + create_collection_schemas(&collection, lexicon_data, &mut schemas); 160 + } 161 + 162 + let spec = OpenApiSpec { 163 + openapi: "3.0.3".to_string(), 164 + info: OpenApiInfo { 165 + title: format!("Slice API: {}", params.slice), 166 + version: "1.0.0".to_string(), 167 + description: format!("Dynamically generated OpenAPI specification for slice: {}", params.slice), 168 + contact: OpenApiContact { 169 + name: "Slice API".to_string(), 170 + url: "https://github.com/anthropics/slice".to_string(), 171 + }, 172 + }, 173 + servers: vec![OpenApiServer { 174 + url: "/xrpc".to_string(), 175 + description: "XRPC endpoint base".to_string(), 176 + }], 177 + paths, 178 + components: OpenApiComponents { 179 + schemas, 180 + security_schemes: Some(create_security_schemes()), 181 + }, 182 + }; 183 + 184 + Ok(Json(spec)) 185 + } 186 + 187 + fn create_collection_paths(collection: &str, slice_uri: &str, lexicon_data: Option<&serde_json::Value>, paths: &mut HashMap<String, HashMap<String, OpenApiOperation>>) { 188 + // List operation (GET) 189 + let list_path = format!("/{}.list", collection); 190 + let mut list_operations = HashMap::new(); 191 + list_operations.insert("get".to_string(), OpenApiOperation { 192 + operation_id: format!("list{}", collection.replace(".", "_")), 193 + summary: format!("List {} records", collection), 194 + description: format!("List records from the {} collection", collection), 195 + parameters: Some(vec![ 196 + OpenApiParameter { 197 + name: "slice".to_string(), 198 + location: "query".to_string(), 199 + description: "Slice URI to filter records by".to_string(), 200 + required: true, 201 + schema: string_schema_with_default(slice_uri), 202 + example: Some(slice_uri.to_string()), 203 + }, 204 + OpenApiParameter { 205 + name: "author".to_string(), 206 + location: "query".to_string(), 207 + description: "Filter by author DID".to_string(), 208 + required: false, 209 + schema: OpenApiSchema { 210 + schema_type: "string".to_string(), 211 + format: None, 212 + items: None, 213 + properties: None, 214 + required: None, 215 + default: None, 216 + }, 217 + example: None, 218 + }, 219 + OpenApiParameter { 220 + name: "limit".to_string(), 221 + location: "query".to_string(), 222 + description: "Maximum number of records to return".to_string(), 223 + required: false, 224 + schema: OpenApiSchema { 225 + schema_type: "integer".to_string(), 226 + format: Some("int32".to_string()), 227 + items: None, 228 + properties: None, 229 + required: None, 230 + default: None, 231 + }, 232 + example: None, 233 + }, 234 + OpenApiParameter { 235 + name: "cursor".to_string(), 236 + location: "query".to_string(), 237 + description: "Pagination cursor".to_string(), 238 + required: false, 239 + schema: OpenApiSchema { 240 + schema_type: "string".to_string(), 241 + format: None, 242 + items: None, 243 + properties: None, 244 + required: None, 245 + default: None, 246 + }, 247 + example: None, 248 + }, 249 + ]), 250 + request_body: None, 251 + responses: create_list_responses(), 252 + tags: vec![collection.to_string()], 253 + security: None, // No auth required for read operations 254 + }); 255 + paths.insert(list_path, list_operations); 256 + 257 + // Get operation (GET) 258 + let get_path = format!("/{}.get", collection); 259 + let mut get_operations = HashMap::new(); 260 + get_operations.insert("get".to_string(), OpenApiOperation { 261 + operation_id: format!("get{}", collection.replace(".", "_")), 262 + summary: format!("Get {} record", collection), 263 + description: format!("Get a specific record from the {} collection", collection), 264 + parameters: Some(vec![ 265 + OpenApiParameter { 266 + name: "slice".to_string(), 267 + location: "query".to_string(), 268 + description: "Slice URI to filter records by".to_string(), 269 + required: true, 270 + schema: string_schema_with_default(slice_uri), 271 + example: Some(slice_uri.to_string()), 272 + }, 273 + OpenApiParameter { 274 + name: "uri".to_string(), 275 + location: "query".to_string(), 276 + description: "AT Protocol URI of the record".to_string(), 277 + required: true, 278 + schema: OpenApiSchema { 279 + schema_type: "string".to_string(), 280 + format: None, 281 + items: None, 282 + properties: None, 283 + required: None, 284 + default: None, 285 + }, 286 + example: None, 287 + }, 288 + ]), 289 + request_body: None, 290 + responses: create_get_responses(), 291 + tags: vec![collection.to_string()], 292 + security: None, // No auth required for read operations 293 + }); 294 + paths.insert(get_path, get_operations); 295 + 296 + // Search operation (GET) 297 + let search_path = format!("/{}.searchRecords", collection); 298 + let mut search_operations = HashMap::new(); 299 + search_operations.insert("get".to_string(), OpenApiOperation { 300 + operation_id: format!("search{}", collection.replace(".", "_")), 301 + summary: format!("Search {} records", collection), 302 + description: format!("Search records in the {} collection", collection), 303 + parameters: Some(vec![ 304 + OpenApiParameter { 305 + name: "slice".to_string(), 306 + location: "query".to_string(), 307 + description: "Slice URI to filter records by".to_string(), 308 + required: true, 309 + schema: string_schema_with_default(slice_uri), 310 + example: Some(slice_uri.to_string()), 311 + }, 312 + OpenApiParameter { 313 + name: "query".to_string(), 314 + location: "query".to_string(), 315 + description: "Search query string".to_string(), 316 + required: true, 317 + schema: OpenApiSchema { 318 + schema_type: "string".to_string(), 319 + format: None, 320 + items: None, 321 + properties: None, 322 + required: None, 323 + default: None, 324 + }, 325 + example: None, 326 + }, 327 + OpenApiParameter { 328 + name: "field".to_string(), 329 + location: "query".to_string(), 330 + description: "Specific field to search in".to_string(), 331 + required: false, 332 + schema: OpenApiSchema { 333 + schema_type: "string".to_string(), 334 + format: None, 335 + items: None, 336 + properties: None, 337 + required: None, 338 + default: None, 339 + }, 340 + example: None, 341 + }, 342 + OpenApiParameter { 343 + name: "limit".to_string(), 344 + location: "query".to_string(), 345 + description: "Maximum number of records to return".to_string(), 346 + required: false, 347 + schema: OpenApiSchema { 348 + schema_type: "integer".to_string(), 349 + format: Some("int32".to_string()), 350 + items: None, 351 + properties: None, 352 + required: None, 353 + default: None, 354 + }, 355 + example: None, 356 + }, 357 + OpenApiParameter { 358 + name: "cursor".to_string(), 359 + location: "query".to_string(), 360 + description: "Pagination cursor".to_string(), 361 + required: false, 362 + schema: OpenApiSchema { 363 + schema_type: "string".to_string(), 364 + format: None, 365 + items: None, 366 + properties: None, 367 + required: None, 368 + default: None, 369 + }, 370 + example: None, 371 + }, 372 + ]), 373 + request_body: None, 374 + responses: create_list_responses(), 375 + tags: vec![collection.to_string()], 376 + security: None, // No auth required for read operations 377 + }); 378 + paths.insert(search_path, search_operations); 379 + 380 + // Create operation (POST) 381 + let create_path = format!("/{}.create", collection); 382 + let mut create_operations = HashMap::new(); 383 + 384 + // Generate schema from lexicon if available 385 + let record_schema = if let Some(lexicon) = lexicon_data { 386 + create_record_schema_from_lexicon(Some(lexicon)) 387 + } else { 388 + OpenApiSchema { 389 + schema_type: "object".to_string(), 390 + format: None, 391 + items: None, 392 + properties: None, 393 + required: None, 394 + default: None, 395 + } 396 + }; 397 + 398 + create_operations.insert("post".to_string(), OpenApiOperation { 399 + operation_id: format!("create{}", collection.replace(".", "_")), 400 + summary: format!("Create {} record", collection), 401 + description: format!("Create a new record in the {} collection", collection), 402 + parameters: None, 403 + request_body: Some(OpenApiRequestBody { 404 + description: "Record data to create".to_string(), 405 + content: { 406 + let mut content = HashMap::new(); 407 + content.insert("application/json".to_string(), OpenApiMediaType { 408 + schema: record_schema.clone(), 409 + }); 410 + content 411 + }, 412 + required: true, 413 + }), 414 + responses: create_mutation_responses(), 415 + tags: vec![collection.to_string()], 416 + security: Some(create_bearer_auth_security()), // Auth required for mutations 417 + }); 418 + paths.insert(create_path, create_operations); 419 + 420 + // Update operation (POST) 421 + let update_path = format!("/{}.update", collection); 422 + let mut update_operations = HashMap::new(); 423 + update_operations.insert("post".to_string(), OpenApiOperation { 424 + operation_id: format!("update{}", collection.replace(".", "_")), 425 + summary: format!("Update {} record", collection), 426 + description: format!("Update an existing record in the {} collection", collection), 427 + parameters: None, 428 + request_body: Some(OpenApiRequestBody { 429 + description: "Record data and rkey to update".to_string(), 430 + content: { 431 + let mut content = HashMap::new(); 432 + content.insert("application/json".to_string(), OpenApiMediaType { 433 + schema: OpenApiSchema { 434 + schema_type: "object".to_string(), 435 + format: None, 436 + items: None, 437 + properties: Some({ 438 + let mut props = HashMap::new(); 439 + props.insert("rkey".to_string(), OpenApiSchema { 440 + schema_type: "string".to_string(), 441 + format: None, 442 + items: None, 443 + properties: None, 444 + required: None, 445 + default: None, 446 + }); 447 + props.insert("record".to_string(), record_schema.clone()); 448 + props 449 + }), 450 + required: Some(vec!["rkey".to_string(), "record".to_string()]), 451 + default: None, 452 + }, 453 + }); 454 + content 455 + }, 456 + required: true, 457 + }), 458 + responses: create_mutation_responses(), 459 + tags: vec![collection.to_string()], 460 + security: Some(create_bearer_auth_security()), // Auth required for mutations 461 + }); 462 + paths.insert(update_path, update_operations); 463 + 464 + // Delete operation (POST) 465 + let delete_path = format!("/{}.delete", collection); 466 + let mut delete_operations = HashMap::new(); 467 + delete_operations.insert("post".to_string(), OpenApiOperation { 468 + operation_id: format!("delete{}", collection.replace(".", "_")), 469 + summary: format!("Delete {} record", collection), 470 + description: format!("Delete a record from the {} collection", collection), 471 + parameters: None, 472 + request_body: Some(OpenApiRequestBody { 473 + description: "Record key to delete".to_string(), 474 + content: { 475 + let mut content = HashMap::new(); 476 + content.insert("application/json".to_string(), OpenApiMediaType { 477 + schema: OpenApiSchema { 478 + schema_type: "object".to_string(), 479 + format: None, 480 + items: None, 481 + properties: Some({ 482 + let mut props = HashMap::new(); 483 + props.insert("rkey".to_string(), OpenApiSchema { 484 + schema_type: "string".to_string(), 485 + format: None, 486 + items: None, 487 + properties: None, 488 + required: None, 489 + default: None, 490 + }); 491 + props 492 + }), 493 + required: Some(vec!["rkey".to_string()]), 494 + default: None, 495 + }, 496 + }); 497 + content 498 + }, 499 + required: true, 500 + }), 501 + responses: create_delete_responses(), 502 + tags: vec![collection.to_string()], 503 + security: Some(create_bearer_auth_security()), // Auth required for mutations 504 + }); 505 + paths.insert(delete_path, delete_operations); 506 + } 507 + 508 + fn create_collection_schemas(_collection: &str, _lexicon_data: Option<&serde_json::Value>, schemas: &mut HashMap<String, OpenApiSchema>) { 509 + // IndexedRecord schema 510 + let mut record_props = HashMap::new(); 511 + record_props.insert("uri".to_string(), OpenApiSchema { 512 + schema_type: "string".to_string(), 513 + format: None, 514 + items: None, 515 + properties: None, 516 + required: None, 517 + default: None, 518 + }); 519 + record_props.insert("cid".to_string(), OpenApiSchema { 520 + schema_type: "string".to_string(), 521 + format: None, 522 + items: None, 523 + properties: None, 524 + required: None, 525 + default: None, 526 + }); 527 + record_props.insert("did".to_string(), OpenApiSchema { 528 + schema_type: "string".to_string(), 529 + format: None, 530 + items: None, 531 + properties: None, 532 + required: None, 533 + default: None, 534 + }); 535 + record_props.insert("collection".to_string(), OpenApiSchema { 536 + schema_type: "string".to_string(), 537 + format: None, 538 + items: None, 539 + properties: None, 540 + required: None, 541 + default: None, 542 + }); 543 + record_props.insert("value".to_string(), OpenApiSchema { 544 + schema_type: "object".to_string(), 545 + format: None, 546 + items: None, 547 + properties: None, 548 + required: None, 549 + default: None, 550 + }); 551 + record_props.insert("indexed_at".to_string(), OpenApiSchema { 552 + schema_type: "string".to_string(), 553 + format: Some("date-time".to_string()), 554 + items: None, 555 + properties: None, 556 + required: None, 557 + default: None, 558 + }); 559 + 560 + schemas.insert("IndexedRecord".to_string(), OpenApiSchema { 561 + schema_type: "object".to_string(), 562 + format: None, 563 + items: None, 564 + properties: Some(record_props), 565 + required: Some(vec!["uri".to_string(), "cid".to_string(), "did".to_string(), "collection".to_string(), "value".to_string(), "indexed_at".to_string()]), 566 + default: None, 567 + }); 568 + 569 + // ListRecordsOutput schema 570 + let mut list_props = HashMap::new(); 571 + list_props.insert("records".to_string(), OpenApiSchema { 572 + schema_type: "array".to_string(), 573 + format: None, 574 + items: Some(Box::new(OpenApiSchema { 575 + schema_type: "object".to_string(), 576 + format: None, 577 + items: None, 578 + properties: None, 579 + required: None, 580 + default: None, 581 + })), 582 + properties: None, 583 + required: None, 584 + default: None, 585 + }); 586 + list_props.insert("cursor".to_string(), OpenApiSchema { 587 + schema_type: "string".to_string(), 588 + format: None, 589 + items: None, 590 + properties: None, 591 + required: None, 592 + default: None, 593 + }); 594 + 595 + schemas.insert("ListRecordsOutput".to_string(), OpenApiSchema { 596 + schema_type: "object".to_string(), 597 + format: None, 598 + items: None, 599 + properties: Some(list_props), 600 + required: Some(vec!["records".to_string()]), 601 + default: None, 602 + }); 603 + } 604 + 605 + fn create_list_responses() -> HashMap<String, OpenApiResponse> { 606 + let mut responses = HashMap::new(); 607 + 608 + responses.insert("200".to_string(), OpenApiResponse { 609 + description: "Successfully retrieved records".to_string(), 610 + content: Some({ 611 + let mut content = HashMap::new(); 612 + content.insert("application/json".to_string(), OpenApiMediaType { 613 + schema: OpenApiSchema { 614 + schema_type: "object".to_string(), 615 + format: None, 616 + items: None, 617 + properties: None, 618 + required: None, 619 + default: None, 620 + }, 621 + }); 622 + content 623 + }), 624 + }); 625 + 626 + responses.insert("400".to_string(), OpenApiResponse { 627 + description: "Bad request".to_string(), 628 + content: None, 629 + }); 630 + 631 + responses.insert("500".to_string(), OpenApiResponse { 632 + description: "Internal server error".to_string(), 633 + content: None, 634 + }); 635 + 636 + responses 637 + } 638 + 639 + fn create_get_responses() -> HashMap<String, OpenApiResponse> { 640 + let mut responses = HashMap::new(); 641 + 642 + responses.insert("200".to_string(), OpenApiResponse { 643 + description: "Successfully retrieved record".to_string(), 644 + content: Some({ 645 + let mut content = HashMap::new(); 646 + content.insert("application/json".to_string(), OpenApiMediaType { 647 + schema: OpenApiSchema { 648 + schema_type: "object".to_string(), 649 + format: None, 650 + items: None, 651 + properties: None, 652 + required: None, 653 + default: None, 654 + }, 655 + }); 656 + content 657 + }), 658 + }); 659 + 660 + responses.insert("404".to_string(), OpenApiResponse { 661 + description: "Record not found".to_string(), 662 + content: None, 663 + }); 664 + 665 + responses.insert("500".to_string(), OpenApiResponse { 666 + description: "Internal server error".to_string(), 667 + content: None, 668 + }); 669 + 670 + responses 671 + } 672 + 673 + fn create_mutation_responses() -> HashMap<String, OpenApiResponse> { 674 + let mut responses = HashMap::new(); 675 + 676 + responses.insert("200".to_string(), OpenApiResponse { 677 + description: "Successfully created/updated record".to_string(), 678 + content: Some({ 679 + let mut content = HashMap::new(); 680 + content.insert("application/json".to_string(), OpenApiMediaType { 681 + schema: OpenApiSchema { 682 + schema_type: "object".to_string(), 683 + format: None, 684 + items: None, 685 + properties: Some({ 686 + let mut props = HashMap::new(); 687 + props.insert("uri".to_string(), OpenApiSchema { 688 + schema_type: "string".to_string(), 689 + format: None, 690 + items: None, 691 + properties: None, 692 + required: None, 693 + default: None, 694 + }); 695 + props.insert("cid".to_string(), OpenApiSchema { 696 + schema_type: "string".to_string(), 697 + format: None, 698 + items: None, 699 + properties: None, 700 + required: None, 701 + default: None, 702 + }); 703 + props 704 + }), 705 + required: Some(vec!["uri".to_string(), "cid".to_string()]), 706 + default: None, 707 + }, 708 + }); 709 + content 710 + }), 711 + }); 712 + 713 + responses.insert("400".to_string(), OpenApiResponse { 714 + description: "Bad request".to_string(), 715 + content: None, 716 + }); 717 + 718 + responses.insert("401".to_string(), OpenApiResponse { 719 + description: "Unauthorized".to_string(), 720 + content: None, 721 + }); 722 + 723 + responses.insert("500".to_string(), OpenApiResponse { 724 + description: "Internal server error".to_string(), 725 + content: None, 726 + }); 727 + 728 + responses 729 + } 730 + 731 + fn create_delete_responses() -> HashMap<String, OpenApiResponse> { 732 + let mut responses = HashMap::new(); 733 + 734 + responses.insert("200".to_string(), OpenApiResponse { 735 + description: "Successfully deleted record".to_string(), 736 + content: Some({ 737 + let mut content = HashMap::new(); 738 + content.insert("application/json".to_string(), OpenApiMediaType { 739 + schema: OpenApiSchema { 740 + schema_type: "object".to_string(), 741 + format: None, 742 + items: None, 743 + properties: None, 744 + required: None, 745 + default: None, 746 + }, 747 + }); 748 + content 749 + }), 750 + }); 751 + 752 + responses.insert("400".to_string(), OpenApiResponse { 753 + description: "Bad request".to_string(), 754 + content: None, 755 + }); 756 + 757 + responses.insert("401".to_string(), OpenApiResponse { 758 + description: "Unauthorized".to_string(), 759 + content: None, 760 + }); 761 + 762 + responses.insert("404".to_string(), OpenApiResponse { 763 + description: "Record not found".to_string(), 764 + content: None, 765 + }); 766 + 767 + responses.insert("500".to_string(), OpenApiResponse { 768 + description: "Internal server error".to_string(), 769 + content: None, 770 + }); 771 + 772 + responses 773 + } 774 + 775 + fn string_schema_with_default(default_value: &str) -> OpenApiSchema { 776 + OpenApiSchema { 777 + schema_type: "string".to_string(), 778 + format: None, 779 + items: None, 780 + properties: None, 781 + required: None, 782 + default: Some(serde_json::Value::String(default_value.to_string())), 783 + } 784 + } 785 + 786 + fn create_record_schema_from_lexicon(lexicon_data: Option<&serde_json::Value>) -> OpenApiSchema { 787 + if let Some(lexicon) = lexicon_data { 788 + // Get the definitions object directly (it's already parsed JSON, not a string) 789 + if let Some(definitions) = lexicon.get("definitions") { 790 + if let Some(main_def) = definitions.get("main") { 791 + if let Some(record_def) = main_def.get("record") { 792 + if let Some(properties) = record_def.get("properties") { 793 + // Convert lexicon properties to OpenAPI schema properties 794 + let mut openapi_props = HashMap::new(); 795 + let mut required_fields = Vec::new(); 796 + 797 + if let Some(props_obj) = properties.as_object() { 798 + for (prop_name, prop_def) in props_obj { 799 + if let Some(prop_schema) = convert_lexicon_property_to_openapi(prop_def) { 800 + openapi_props.insert(prop_name.clone(), prop_schema); 801 + 802 + // Check if field is required 803 + if let Some(required) = prop_def.get("required") { 804 + if required.as_bool().unwrap_or(false) { 805 + required_fields.push(prop_name.clone()); 806 + } 807 + } 808 + } 809 + } 810 + } 811 + 812 + return OpenApiSchema { 813 + schema_type: "object".to_string(), 814 + format: None, 815 + items: None, 816 + properties: Some(openapi_props), 817 + required: if required_fields.is_empty() { None } else { Some(required_fields) }, 818 + default: None, 819 + }; 820 + } 821 + } 822 + } 823 + } 824 + } 825 + 826 + // Fallback to generic object schema (without rkey - that's a separate request parameter) 827 + OpenApiSchema { 828 + schema_type: "object".to_string(), 829 + format: None, 830 + items: None, 831 + properties: Some(HashMap::new()), 832 + required: None, 833 + default: None, 834 + } 835 + } 836 + 837 + fn convert_lexicon_property_to_openapi(prop_def: &serde_json::Value) -> Option<OpenApiSchema> { 838 + let prop_type = prop_def.get("type")?.as_str()?; 839 + 840 + match prop_type { 841 + "string" => Some(OpenApiSchema { 842 + schema_type: "string".to_string(), 843 + format: None, 844 + items: None, 845 + properties: None, 846 + required: None, 847 + default: None, 848 + }), 849 + "integer" => Some(OpenApiSchema { 850 + schema_type: "integer".to_string(), 851 + format: Some("int64".to_string()), 852 + items: None, 853 + properties: None, 854 + required: None, 855 + default: None, 856 + }), 857 + "boolean" => Some(OpenApiSchema { 858 + schema_type: "boolean".to_string(), 859 + format: None, 860 + items: None, 861 + properties: None, 862 + required: None, 863 + default: None, 864 + }), 865 + "blob" => Some(OpenApiSchema { 866 + schema_type: "object".to_string(), 867 + format: None, 868 + items: None, 869 + properties: Some({ 870 + let mut blob_props = HashMap::new(); 871 + blob_props.insert("$type".to_string(), OpenApiSchema { 872 + schema_type: "string".to_string(), 873 + format: None, 874 + items: None, 875 + properties: None, 876 + required: None, 877 + default: None, 878 + }); 879 + blob_props.insert("ref".to_string(), OpenApiSchema { 880 + schema_type: "object".to_string(), 881 + format: None, 882 + items: None, 883 + properties: Some({ 884 + let mut ref_props = HashMap::new(); 885 + ref_props.insert("$link".to_string(), OpenApiSchema { 886 + schema_type: "string".to_string(), 887 + format: None, 888 + items: None, 889 + properties: None, 890 + required: None, 891 + default: None, 892 + }); 893 + ref_props 894 + }), 895 + required: Some(vec!["$link".to_string()]), 896 + default: None, 897 + }); 898 + blob_props.insert("mimeType".to_string(), OpenApiSchema { 899 + schema_type: "string".to_string(), 900 + format: None, 901 + items: None, 902 + properties: None, 903 + required: None, 904 + default: None, 905 + }); 906 + blob_props.insert("size".to_string(), OpenApiSchema { 907 + schema_type: "integer".to_string(), 908 + format: Some("int64".to_string()), 909 + items: None, 910 + properties: None, 911 + required: None, 912 + default: None, 913 + }); 914 + blob_props 915 + }), 916 + required: Some(vec!["$type".to_string(), "ref".to_string(), "mimeType".to_string(), "size".to_string()]), 917 + default: None, 918 + }), 919 + "array" => { 920 + if let Some(items_def) = prop_def.get("items") { 921 + if let Some(items_schema) = convert_lexicon_property_to_openapi(items_def) { 922 + return Some(OpenApiSchema { 923 + schema_type: "array".to_string(), 924 + format: None, 925 + items: Some(Box::new(items_schema)), 926 + properties: None, 927 + required: None, 928 + default: None, 929 + }); 930 + } 931 + } 932 + Some(OpenApiSchema { 933 + schema_type: "array".to_string(), 934 + format: None, 935 + items: Some(Box::new(OpenApiSchema { 936 + schema_type: "object".to_string(), 937 + format: None, 938 + items: None, 939 + properties: None, 940 + required: None, 941 + default: None, 942 + })), 943 + properties: None, 944 + required: None, 945 + default: None, 946 + }) 947 + }, 948 + "object" => Some(OpenApiSchema { 949 + schema_type: "object".to_string(), 950 + format: None, 951 + items: None, 952 + properties: None, 953 + required: None, 954 + default: None, 955 + }), 956 + _ => Some(OpenApiSchema { 957 + schema_type: "object".to_string(), 958 + format: None, 959 + items: None, 960 + properties: None, 961 + required: None, 962 + default: None, 963 + }), 964 + } 965 + } 966 + 967 + fn create_security_schemes() -> HashMap<String, OpenApiSecurityScheme> { 968 + let mut schemes = HashMap::new(); 969 + 970 + schemes.insert("bearerAuth".to_string(), OpenApiSecurityScheme { 971 + scheme_type: "http".to_string(), 972 + scheme: "bearer".to_string(), 973 + bearer_format: None, // OAuth token, not JWT 974 + description: "OAuth Bearer token authentication. Use your OAuth access token from the auth server.".to_string(), 975 + example: None, 976 + }); 977 + 978 + schemes 979 + } 980 + 981 + fn create_bearer_auth_security() -> Vec<HashMap<String, Vec<String>>> { 982 + let mut auth_requirement = HashMap::new(); 983 + auth_requirement.insert("bearerAuth".to_string(), vec![]); 984 + vec![auth_requirement] 985 + }
+2
api/src/main.rs
··· 4 4 mod database; 5 5 mod errors; 6 6 mod handler_jobs; 7 + mod handler_openapi_spec; 7 8 mod handler_records; 8 9 mod handler_stats; 9 10 mod handler_sync; ··· 129 130 "/xrpc/social.slices.slice.codegen", 130 131 post(handler_xrpc_codegen::generate_client_xrpc), 131 132 ) 133 + .route("/xrpc/social.slices.slice.openapi", get(handler_openapi_spec::get_openapi_spec)) 132 134 // Dynamic collection-specific XRPC endpoints (wildcard routes must come last) 133 135 .route( 134 136 "/xrpc/*method",
+194
frontend/src/pages/SliceApiDocsPage.tsx
··· 1 + interface SliceApiDocsPageProps { 2 + sliceId: string; 3 + sliceName: string; 4 + accessToken?: string; 5 + currentUser: { 6 + isAuthenticated: boolean; 7 + username?: string; 8 + sub?: string; 9 + }; 10 + } 11 + 12 + export function SliceApiDocsPage(props: SliceApiDocsPageProps) { 13 + const { sliceId, sliceName, accessToken, currentUser } = props; 14 + 15 + // Construct the OpenAPI spec URL for this slice 16 + const baseUrl = 17 + typeof window !== "undefined" 18 + ? globalThis.location.origin.replace(":8000", ":3000") // Frontend runs on 8000, API on 3000 19 + : "http://localhost:3000"; 20 + 21 + // Build the slice URI 22 + const sliceUri = `at://${currentUser.sub}/social.slices.slice/${sliceId}`; 23 + const openApiUrl = `${baseUrl}/xrpc/social.slices.slice.openapi?slice=${encodeURIComponent( 24 + sliceUri 25 + )}`; 26 + 27 + return ( 28 + <html lang="en"> 29 + <head> 30 + <meta charset="UTF-8" /> 31 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 32 + <title>API Docs - {sliceName}</title> 33 + <script src="https://cdn.tailwindcss.com"></script> 34 + </head> 35 + <body class="bg-gray-50 min-h-screen"> 36 + {/* Header with back button */} 37 + <div class="bg-white border-b border-gray-200 px-4 py-4"> 38 + <div class="max-w-7xl mx-auto flex items-center justify-between"> 39 + <div class="flex items-center"> 40 + <a 41 + href={`/slices/${sliceId}`} 42 + class="text-blue-600 hover:text-blue-800 mr-4 flex items-center" 43 + > 44 + <svg 45 + class="w-4 h-4 mr-1" 46 + fill="none" 47 + stroke="currentColor" 48 + viewBox="0 0 24 24" 49 + > 50 + <path 51 + stroke-linecap="round" 52 + stroke-linejoin="round" 53 + stroke-width="2" 54 + d="M15 19l-7-7 7-7" 55 + /> 56 + </svg> 57 + Back to {sliceName} 58 + </a> 59 + </div> 60 + <div class="text-right"> 61 + <h1 class="text-xl font-semibold text-gray-900">API Documentation</h1> 62 + <p class="text-gray-600 text-sm"> 63 + Interactive OpenAPI docs for your slice 64 + </p> 65 + </div> 66 + </div> 67 + </div> 68 + 69 + {/* Info bar */} 70 + <div class="bg-blue-50 border-b border-blue-200 px-4 py-3"> 71 + <div class="max-w-7xl mx-auto"> 72 + <p class="text-blue-800 text-sm"> 73 + <strong>OpenAPI Spec URL:</strong> 74 + <code class="ml-2 bg-blue-100 px-2 py-1 rounded text-xs"> 75 + {openApiUrl} 76 + </code> 77 + </p> 78 + </div> 79 + </div> 80 + 81 + {/* Scalar API Reference Container - Scrollable */} 82 + <div class="w-full"> 83 + <div id="scalar-api-reference" class="w-full min-h-screen"> 84 + <div class="flex items-center justify-center h-96"> 85 + <div class="text-center"> 86 + <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div> 87 + <p class="text-gray-500">Loading API documentation...</p> 88 + </div> 89 + </div> 90 + </div> 91 + </div> 92 + 93 + {/* Load Scalar API Reference */} 94 + <script 95 + src="https://cdn.jsdelivr.net/npm/@scalar/api-reference" 96 + async 97 + ></script> 98 + 99 + {/* Initialize Scalar when the script loads */} 100 + <script 101 + dangerouslySetInnerHTML={{ 102 + __html: ` 103 + document.addEventListener('DOMContentLoaded', function() { 104 + // Wait for Scalar to be available 105 + const initScalar = () => { 106 + if (typeof Scalar !== 'undefined' && Scalar.createApiReference) { 107 + try { 108 + Scalar.createApiReference('#scalar-api-reference', { 109 + url: '${openApiUrl}', 110 + configuration: { 111 + theme: 'alternate', 112 + layout: 'modern', 113 + showSidebar: true, 114 + searchHotKey: 'k', 115 + defaultHttpClient: { 116 + targetKey: 'javascript', 117 + clientKey: 'fetch' 118 + } 119 + }, 120 + // Try to set default parameters 121 + variables: { 122 + slice: '${sliceUri}' 123 + }, 124 + // Alternative approach for parameter defaults 125 + defaultParameters: { 126 + slice: '${sliceUri}' 127 + },${accessToken ? ` 128 + authentication: { 129 + preferredSecurityScheme: 'bearerAuth', 130 + http: { 131 + bearer: { 132 + token: '${accessToken}' 133 + } 134 + } 135 + },` : ''} 136 + customCss: \` 137 + .scalar-api-reference { 138 + width: 100% !important; 139 + border: none !important; 140 + min-height: 100vh; 141 + height: auto !important; 142 + } 143 + .scalar-api-reference > div { 144 + height: auto !important; 145 + } 146 + \`, 147 + onReady: () => { 148 + console.log('Scalar API Reference loaded successfully'); 149 + }, 150 + onError: (error) => { 151 + console.error('Failed to load API documentation:', error); 152 + document.getElementById('scalar-api-reference').innerHTML = \` 153 + <div class="flex items-center justify-center h-64 text-center"> 154 + <div> 155 + <div class="text-red-500 mb-4"> 156 + <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 157 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 158 + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"/> 159 + </svg> 160 + </div> 161 + <h3 class="text-lg font-medium text-gray-900 mb-2">Failed to load API documentation</h3> 162 + <p class="text-gray-600 text-sm mb-4"> 163 + Unable to fetch the OpenAPI specification. Please make sure the API server is running. 164 + </p> 165 + <button 166 + onclick="window.location.reload()" 167 + class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium" 168 + > 169 + Retry 170 + </button> 171 + </div> 172 + </div> 173 + \`; 174 + } 175 + }); 176 + } catch (error) { 177 + console.error('Error initializing Scalar:', error); 178 + } 179 + } else { 180 + // Retry after a short delay 181 + setTimeout(initScalar, 100); 182 + } 183 + }; 184 + 185 + // Start initialization 186 + initScalar(); 187 + }); 188 + `, 189 + }} 190 + /> 191 + </body> 192 + </html> 193 + ); 194 + }
+15
frontend/src/pages/SlicePage.tsx
··· 119 119 120 120 <div className="bg-white rounded-lg shadow-md p-6"> 121 121 <h2 className="text-xl font-semibold text-gray-800 mb-4"> 122 + 📖 API Documentation 123 + </h2> 124 + <p className="text-gray-600 mb-4"> 125 + Interactive OpenAPI documentation for your slice's XRPC endpoints. 126 + </p> 127 + <a 128 + href={`/slices/${sliceId}/api-docs`} 129 + className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded" 130 + > 131 + View API Docs 132 + </a> 133 + </div> 134 + 135 + <div className="bg-white rounded-lg shadow-md p-6"> 136 + <h2 className="text-xl font-semibold text-gray-800 mb-4"> 122 137 🔄 Sync 123 138 </h2> 124 139 <p className="text-gray-600 mb-4">
+70 -1
frontend/src/routes/pages.tsx
··· 10 10 import { SliceSyncPage } from "../pages/SliceSyncPage.tsx"; 11 11 import { SliceLexiconPage } from "../pages/SliceLexiconPage.tsx"; 12 12 import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx"; 13 + import { SliceApiDocsPage } from "../pages/SliceApiDocsPage.tsx"; 13 14 import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx"; 14 15 import { SettingsPage } from "../pages/SettingsPage.tsx"; 15 16 16 17 async function handleIndexPage(req: Request): Promise<Response> { 17 18 const context = await withAuth(req); 18 - 19 + 19 20 // Slice list page - get real slices from AT Protocol 20 21 let slices: Array<{ id: string; name: string; createdAt: string }> = []; 21 22 ··· 357 358 }); 358 359 } 359 360 361 + async function handleSliceApiDocsPage( 362 + req: Request, 363 + params?: URLPatternResult 364 + ): Promise<Response> { 365 + const context = await withAuth(req); 366 + const sliceId = params?.pathname.groups.id; 367 + 368 + if (!sliceId) { 369 + return Response.redirect(new URL("/", req.url), 302); 370 + } 371 + 372 + // Get OAuth access token if available 373 + let accessToken: string | undefined; 374 + try { 375 + const tokens = await atprotoClient.oauth?.ensureValidToken(); 376 + accessToken = tokens?.accessToken; 377 + } catch (error) { 378 + console.log("Could not get OAuth token:", error); 379 + } 380 + 381 + // Get real slice data from AT Protocol 382 + let sliceData = { 383 + sliceId, 384 + sliceName: "Unknown Slice", 385 + accessToken, 386 + }; 387 + 388 + if (context.currentUser.isAuthenticated) { 389 + try { 390 + const sliceUri = buildAtUri({ 391 + did: context.currentUser.sub!, 392 + collection: "social.slices.slice", 393 + rkey: sliceId, 394 + }); 395 + 396 + const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ 397 + uri: sliceUri, 398 + }); 399 + 400 + sliceData = { 401 + sliceId, 402 + sliceName: sliceRecord.value.name, 403 + accessToken, 404 + }; 405 + } catch (error) { 406 + console.error("Failed to fetch slice data:", error); 407 + // Fall back to default data 408 + } 409 + } 410 + 411 + const html = render( 412 + <SliceApiDocsPage {...sliceData} currentUser={context.currentUser} /> 413 + ); 414 + 415 + const responseHeaders: Record<string, string> = { 416 + "content-type": "text/html", 417 + }; 418 + 419 + return new Response(`<!DOCTYPE html>${html}`, { 420 + status: 200, 421 + headers: responseHeaders, 422 + }); 423 + } 424 + 360 425 async function handleSettingsPage(req: Request): Promise<Response> { 361 426 const context = await withAuth(req); 362 427 ··· 424 489 { 425 490 pattern: new URLPattern({ pathname: "/slices/:id" }), 426 491 handler: handleSlicePage, 492 + }, 493 + { 494 + pattern: new URLPattern({ pathname: "/slices/:id/api-docs" }), 495 + handler: handleSliceApiDocsPage, 427 496 }, 428 497 { 429 498 pattern: new URLPattern({ pathname: "/slices/:id/:tab" }),