gestalt-sdk 0.0.1-alpha.18

Rust SDK scaffolding and generated protocol bindings for Gestalt executable providers
Documentation
use serde_json::Value;

use crate::OperationResult;

#[derive(Clone, Debug, thiserror::Error)]
#[error("{message}")]
/// Decoded app invocation failure.
pub struct InvokeError {
    pub app: String,
    pub operation: String,
    pub status: Option<u16>,
    pub code: Option<String>,
    pub message: String,
    pub body: Option<Box<Value>>,
    pub raw_body: Vec<u8>,
}

pub(crate) fn decode_app_result(
    app: &str,
    operation: &str,
    result: &OperationResult,
) -> Result<Value, Box<InvokeError>> {
    decode_app_body(app, operation, result.status, &result.body)
}

pub(crate) fn decode_graphql_result(
    app: &str,
    result: &OperationResult,
) -> Result<Value, Box<InvokeError>> {
    let decoded = decode_app_result(app, "graphql", result)?;
    if let Ok(parsed) = parse_operation_result_json(&result.body) {
        raise_graphql_errors(app, &result.body, &parsed)?;
    }
    raise_graphql_errors(app, &result.body, &decoded)?;
    Ok(decoded)
}

fn raise_graphql_errors(app: &str, raw_body: &[u8], value: &Value) -> Result<(), Box<InvokeError>> {
    if let Value::Object(object) = value {
        if let Some(Value::Array(errors)) = object.get("errors") {
            if !errors.is_empty() {
                return Err(Box::new(InvokeError {
                    app: app.to_string(),
                    operation: "graphql".to_string(),
                    status: None,
                    code: Some("graphql_errors".to_string()),
                    message: graphql_error_message(errors),
                    body: Some(Box::new(value.clone())),
                    raw_body: raw_body.to_vec(),
                }));
            }
        }
    }
    Ok(())
}

pub(crate) fn parse_operation_result_json(body: &[u8]) -> Result<Value, serde_json::Error> {
    if body.iter().all(u8::is_ascii_whitespace) {
        return Ok(serde_json::json!({}));
    }
    serde_json::from_slice(body)
}

fn decode_app_body(
    app: &str,
    operation: &str,
    status: u16,
    body: &[u8],
) -> Result<Value, Box<InvokeError>> {
    let raw_body = body.to_vec();
    let parsed = match parse_operation_result_json(body) {
        Ok(value) => value,
        Err(_) if status >= 400 => {
            return Err(Box::new(InvokeError {
                app: app.to_string(),
                operation: operation.to_string(),
                status: Some(status),
                code: None,
                message: format!("app invoke failed with status {status}"),
                body: None,
                raw_body,
            }));
        }
        Err(_) => {
            return Err(Box::new(InvokeError {
                app: app.to_string(),
                operation: operation.to_string(),
                status: None,
                code: None,
                message: "app invoke response is not valid JSON".to_string(),
                body: None,
                raw_body,
            }));
        }
    };

    if status >= 400 {
        let (message, code) = message_code_from_body(&parsed);
        return Err(Box::new(InvokeError {
            app: app.to_string(),
            operation: operation.to_string(),
            status: Some(status),
            code,
            message: message.unwrap_or_else(|| format!("app invoke failed with status {status}")),
            body: Some(Box::new(parsed)),
            raw_body,
        }));
    }

    if let Value::Object(object) = &parsed {
        if let Some(Value::String(status)) = object.get("status") {
            if status == "error" {
                let (message, code) = message_code_from_body(&parsed);
                return Err(Box::new(InvokeError {
                    app: app.to_string(),
                    operation: operation.to_string(),
                    status: None,
                    code,
                    message: message.unwrap_or_else(|| "app invoke failed".to_string()),
                    body: Some(Box::new(parsed)),
                    raw_body: raw_body.clone(),
                }));
            }
            if status == "success" && object.contains_key("data") {
                return Ok(object.get("data").cloned().unwrap_or(Value::Null));
            }
        }
    }
    Ok(parsed)
}

fn message_code_from_body(value: &Value) -> (Option<String>, Option<String>) {
    let Some(object) = value.as_object() else {
        return (None, None);
    };
    let nested = object.get("error").and_then(Value::as_object);
    let message = nested
        .and_then(|error| error.get("message"))
        .and_then(Value::as_str)
        .or_else(|| object.get("message").and_then(Value::as_str))
        .filter(|value| !value.trim().is_empty())
        .map(str::to_string);
    let code = nested
        .and_then(|error| error.get("code"))
        .and_then(Value::as_str)
        .or_else(|| object.get("code").and_then(Value::as_str))
        .filter(|value| !value.trim().is_empty())
        .map(str::to_string);
    (message, code)
}

fn graphql_error_message(errors: &[Value]) -> String {
    errors
        .first()
        .and_then(Value::as_object)
        .and_then(|error| error.get("message"))
        .and_then(Value::as_str)
        .filter(|message| !message.trim().is_empty())
        .unwrap_or("GraphQL returned errors")
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn result(body: &str, status: u16) -> OperationResult {
        OperationResult {
            status,
            headers: Default::default(),
            body: body.as_bytes().to_vec(),
        }
    }

    #[test]
    fn decodes_shared_app_fixtures() {
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/success_envelope.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({ "id": 1 })
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(include_str!("../../testdata/app_invoke/plain_ok.json"), 200)
            )
            .unwrap(),
            serde_json::json!({ "pull_request": { "id": 123, "title": "Fix transport" } })
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/empty_body.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({})
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/success_missing_data.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({ "status": "success", "ok": true })
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/success_null_data.json"),
                    200
                )
            )
            .unwrap(),
            Value::Null
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/unknown_status.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({ "status": "pending", "data": { "id": 2 } })
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/non_string_status.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({ "status": true, "data": { "id": 3 } })
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(include_str!("../../testdata/app_invoke/array_ok.json"), 200)
            )
            .unwrap(),
            serde_json::json!([1, 2, 3])
        );
        assert_eq!(
            decode_app_result(
                "github",
                "get_issue",
                &result(
                    include_str!("../../testdata/app_invoke/primitive_ok.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!("ok")
        );
    }

    #[test]
    fn decodes_shared_error_fixtures() {
        let envelope = decode_app_result(
            "github",
            "get_issue",
            &result(
                include_str!("../../testdata/app_invoke/error_envelope.json"),
                200,
            ),
        )
        .expect_err("error envelope");
        assert_eq!(envelope.code.as_deref(), Some("missing_credential"));
        assert_eq!(envelope.message, "missing credential");

        let http = decode_app_result(
            "github",
            "get_issue",
            &result(include_str!("../../testdata/app_invoke/http_401.json"), 401),
        )
        .expect_err("http error");
        assert_eq!(http.status, Some(401));
        assert!(!http.raw_body.is_empty());

        decode_app_result(
            "github",
            "get_issue",
            &result(
                include_str!("../../testdata/app_invoke/invalid_json.txt"),
                200,
            ),
        )
        .expect_err("invalid json");
    }

    #[test]
    fn decodes_graphql_errors() {
        assert_eq!(
            decode_graphql_result(
                "linear",
                &result(
                    include_str!("../../testdata/app_invoke/graphql_ok.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({ "data": { "viewer": { "id": "user-1" } }, "errors": [] })
        );
        assert_eq!(
            decode_graphql_result(
                "linear",
                &result(
                    include_str!("../../testdata/app_invoke/graphql_malformed_errors.json"),
                    200
                )
            )
            .unwrap(),
            serde_json::json!({ "data": { "viewer": null }, "errors": { "message": "not an array" } })
        );
        let err = decode_graphql_result(
            "linear",
            &result(
                include_str!("../../testdata/app_invoke/graphql_errors.json"),
                200,
            ),
        )
        .expect_err("graphql errors");
        assert_eq!(err.code.as_deref(), Some("graphql_errors"));
        let envelope_err = decode_graphql_result(
            "linear",
            &result(
                include_str!("../../testdata/app_invoke/graphql_success_envelope_errors.json"),
                200,
            ),
        )
        .expect_err("enveloped graphql errors");
        assert_eq!(envelope_err.code.as_deref(), Some("graphql_errors"));
    }
}