firecrawl 2.21.0

Official Rust SDK for Firecrawl API v2.
Documentation
use firecrawl::{
    AlexandriaCall, AlexandriaOptions, Client, FirecrawlError, SearchOptions, SearchSource,
};
use mockito::Matcher;
use serde_json::json;

#[tokio::test]
async fn unified_contracts_and_execution_identity() {
    let mut server = mockito::Server::new_async().await;
    let mut tool = json!({"id":"p/a","provider":"p","capability":"a","name":"Tool","description":"Example","creditsCost":2,"perRecord":false,"options":[{"name":"q","type":"string"}],"response":{"fields":[]},"examples":{},"matchedBy":["semantic","domain"],"matchedUrls":["https://example.com"]});
    tool["requiresOneOf"] = json!([["q", "url"]]);
    tool["example"] = json!({"q": "test"});
    tool["concept"] = json!("search");
    tool["cohorts"] = json!(["research"]);
    tool["similarity"] = json!(0.9);
    tool["futureField"] = json!(true);
    let production_tool = json!({"id":"benzinga/calendar/ratings","provider":"benzinga","capability":"calendar/ratings","name":"Analyst ratings","description":"Ratings","creditsCost":5,"perRecord":false,"label":"Ratings","whenToUse":"Analyst ratings for a ticker","returns":{"about":"Ratings"},"discovery":{"urls":[]},"attribution":{"required":true},"options":[{"name":"tickers","type":"string"}],"response":{"fields":[]},"matchedBy":["semantic"],"matchedUrls":[]});
    let search = server
        .mock("POST", "/v2/search")
        .match_body(Matcher::PartialJson(
            json!({"query":"tools","sources":["alexandria"],"domainTools":true}),
        ))
        .with_header("content-type", "application/json")
        .with_body(
            json!({"success":true,"data":{"tools":[tool.clone(), production_tool.clone()]}})
                .to_string(),
        )
        .create_async()
        .await;
    let client = Client::new_selfhosted(server.url(), Some("fc-test")).unwrap();
    let found = client
        .search(
            "tools",
            SearchOptions {
                sources: Some(vec![SearchSource::Alexandria]),
                domain_tools: Some(true),
                ..Default::default()
            },
        )
        .await
        .unwrap();
    let tools = found.data.tools.as_ref().unwrap();
    assert_eq!(serde_json::to_value(&tools[0]).unwrap(), tool);
    assert_eq!(
        tools[0].requires_one_of,
        Some(vec![vec!["q".into(), "url".into()]])
    );
    assert_eq!(tools[0].example, Some(json!({"q": "test"})));
    assert_eq!(tools[0].concept.as_deref(), Some("search"));
    assert_eq!(tools[0].cohorts, Some(vec!["research".into()]));
    assert_eq!(tools[0].similarity, Some(0.9));
    assert_eq!(tools[0].extra.get("futureField"), Some(&json!(true)));
    assert!(tools[1].requires_one_of.is_none());
    assert!(tools[1].examples.is_empty());
    assert_eq!(tools[1].label.as_deref(), Some("Ratings"));
    assert_eq!(
        tools[1].when_to_use.as_deref(),
        Some("Analyst ratings for a ticker")
    );
    assert_eq!(tools[1].matched_by, vec!["semantic"]);
    search.assert_async().await;
    let terms = server
        .mock("POST", "/v2/scrape")
        .match_header("x-request-id", "terms-1")
        .with_status(403)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"success":false,"code":"THIRD_PARTY_DATA_TERMS_REQUIRED","error":"An organization admin must accept the benzinga provider's terms","requiresAction":{"type":"accept_terms","terms":"benzinga","version":"C-1.0.0-draft","url":"https://www.firecrawl.dev/app/alexandria/benzinga"}}"#,
        )
        .create_async()
        .await;
    match client
        .scrape_alexandria(
            vec![AlexandriaCall {
                provider: "benzinga".into(),
                capability: "calendar/ratings".into(),
                options: None,
            }],
            AlexandriaOptions {
                request_id: Some("terms-1".into()),
                ..Default::default()
            },
        )
        .await
        .unwrap_err()
    {
        FirecrawlError::AlexandriaExecution { request_id, source } => {
            assert_eq!(request_id, "terms-1");
            match *source {
                FirecrawlError::APIError(_, error) => {
                    assert_eq!(
                        error.code.as_deref(),
                        Some("THIRD_PARTY_DATA_TERMS_REQUIRED")
                    );
                    let action = error.requires_action.expect("requires_action");
                    assert_eq!(action.kind, "accept_terms");
                    assert_eq!(action.terms.as_deref(), Some("benzinga"));
                    assert_eq!(action.version.as_deref(), Some("C-1.0.0-draft"));
                    assert_eq!(
                        action.url.as_deref(),
                        Some("https://www.firecrawl.dev/app/alexandria/benzinga")
                    );
                }
                _ => panic!("missing terms API error"),
            }
        }
        _ => panic!("missing terms execution identity"),
    }
    terms.assert_async().await;
    let denied = server
        .mock("POST", "/v2/scrape")
        .match_header("x-request-id", "denied-1")
        .match_body(Matcher::PartialJson(
            json!({"alexandria":[{"provider":"p","capability":"a"}]}),
        ))
        .with_status(402)
        .with_header("content-type", "application/json")
        .with_body(
            r#"{"success":false,"error":"Insufficient credits","code":"insufficient_credits"}"#,
        )
        .create_async()
        .await;
    let error = client
        .scrape_alexandria(
            vec![AlexandriaCall {
                provider: "p".into(),
                capability: "a".into(),
                options: None,
            }],
            AlexandriaOptions {
                request_id: Some("denied-1".into()),
                ..Default::default()
            },
        )
        .await
        .unwrap_err();
    match error {
        FirecrawlError::AlexandriaExecution { request_id, source } => {
            assert_eq!(request_id, "denied-1");
            assert!(matches!(*source, FirecrawlError::APIError(_, _)));
        }
        _ => panic!("missing execution identity"),
    }
    denied.assert_async().await;
    assert!(matches!(
        client.search("   ", None).await,
        Err(FirecrawlError::Misuse(_))
    ));
    let invalid = server.mock("POST", "/v2/scrape")
        .with_header("content-type", "application/json")
        .with_body(r#"{"success":true,"scrape_id":"s1","data":{"alexandria":[{"error":{"code":"invalid_options","message":"Invalid lookup"}}],"creditsCost":0}}"#)
        .create_async().await;
    match client.find_tools(None).await.unwrap_err() {
        FirecrawlError::AlexandriaExecution { request_id, source } => {
            assert!(!request_id.is_empty());
            match *source {
                FirecrawlError::APIError(_, error) => {
                    assert_eq!(error.code.as_deref(), Some("invalid_options"))
                }
                _ => panic!("missing lookup error code"),
            }
        }
        _ => panic!("missing lookup request ID"),
    }
    invalid.assert_async().await;
}

#[test]
fn compact_discovery_models() {
    let compact = json!({"provider":"p","capability":"search","description":"Find records"});
    let tool: firecrawl::DiscoveredTool = serde_json::from_value(compact).unwrap();
    assert!(tool.name.is_none());
    assert!(tool.credits_cost.is_none());
    assert!(tool.per_record.is_none());
    let search = SearchOptions {
        tool_detail: Some(firecrawl::ToolDetail::Compact),
        ..Default::default()
    };
    assert_eq!(
        serde_json::to_value(search).unwrap()["toolDetail"],
        "compact"
    );
    let scrape = firecrawl::ScrapeOptions {
        tool_detail: Some(firecrawl::ToolDetail::Compact),
        ..Default::default()
    };
    assert_eq!(
        serde_json::to_value(scrape).unwrap()["toolDetail"],
        "compact"
    );
}