Highly ambitious ATProtocol AppView service and sdks
138
fork

Configure Feed

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

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" }),