psibase 0.23.0

Library and command-line tool for interacting with psibase networks
Documentation
use crate::{
    allow_cors_with_origin, check, generate_action_templates, HttpReply, HttpRequest,
    ProcessActionStruct, ToServiceSchema, WithActionStruct,
};
use async_graphql::{
    http::{receive_body, GraphiQLSource},
    EmptyMutation, EmptySubscription,
};
use futures::executor::block_on;

const SIMPLE_UI: &[u8] = br#"<html><div id="root" class="ui container"></div><script src="/common/SimpleUI.mjs" type="module"></script></html>"#;

/// Serve a developer UI
///
/// This function serves a simple developer UI to help get you started. The UI it
/// generates is not suitable for end users.
///
/// This serves the following:
/// - `GET /` or `GET /index.html`: provided by [serve_simple_index]
/// - `GET /action_templates`: provided by [serve_action_templates]
/// - `POST /pack_action/x`: provided by [serve_pack_action]
///
/// `Wrapper` should be generated by [`#[psibase::service]`](macro@crate::service).
pub fn serve_simple_ui<Wrapper: WithActionStruct + ToServiceSchema>(
    request: &HttpRequest,
) -> Option<HttpReply> {
    None.or_else(|| serve_simple_index(request))
        .or_else(|| serve_action_templates::<Wrapper>(request))
        .or_else(|| serve_pack_action::<Wrapper>(request))
}

/// Serve index.html for a developer UI
///
/// This function serves the following HTML for `GET /` or `GET /index.html`:
///
/// ```html
/// <html>
///     <div id="root" class="ui container"></div>
///     <script src="/common/SimpleUI.mjs" type="module"></script>
/// </html>
/// ```
pub fn serve_simple_index(request: &HttpRequest) -> Option<HttpReply> {
    if request.method == "GET" && (request.target == "/" || request.target == "/index.html") {
        Some(HttpReply {
            status: 200,
            contentType: "text/html".into(),
            body: SIMPLE_UI.to_vec().into(),
            headers: allow_cors_with_origin("*"),
        })
    } else {
        None
    }
}

/// Handle `/action_templates` request
///
/// If `request` is a `GET /action_templates`, then this returns a
/// JSON object containing a field for each action in `Service`. The
/// field names match the action names. The field values are objects
/// with the action arguments, each containing sample data.
///
/// If `request` doesn't match the above, then this returns `None`.
///
/// `Wrapper` should be generated by [`#[psibase::service]`](macro@crate::service).
pub fn serve_action_templates<Wrapper: ToServiceSchema>(
    request: &HttpRequest,
) -> Option<HttpReply> {
    if request.method == "GET" && request.target == "/action_templates" {
        Some(HttpReply {
            status: 200,
            contentType: "text/html".into(),
            body: generate_action_templates::<Wrapper>().into(),
            headers: allow_cors_with_origin("*"),
        })
    } else {
        None
    }
}

/// Handle `/pack_action/` request
///
/// If `request` is a `POST /pack_action/x`, where `x` is an action
/// on `Wrapper`, then this parses a JSON object containing the arguments
/// to `x`, packs them using fracpack, and returns the result as an
/// `application/octet-stream`.
///
/// If `request` doesn't match the above, or the action name is not found,
/// then this returns `None`.
///
/// `Wrapper` should be generated by [`#[psibase::service]`](macro@crate::service).
pub fn serve_pack_action<Wrapper: WithActionStruct>(request: &HttpRequest) -> Option<HttpReply> {
    struct PackAction<'a>(&'a [u8]);

    impl<'a> ProcessActionStruct for PackAction<'a> {
        type Output = HttpReply;

        fn process<
            Return: serde::Serialize + serde::de::DeserializeOwned,
            ArgStruct: fracpack::Pack + serde::Serialize + serde::de::DeserializeOwned,
        >(
            self,
        ) -> Self::Output {
            let arg_struct_result = serde_json::from_slice::<ArgStruct>(self.0);
            if let Err(err) = &arg_struct_result {
                check(false, &format!("err parsing action args json {}", err));
            }
            HttpReply {
                status: 200,
                contentType: "application/octet-stream".into(),
                body: arg_struct_result.unwrap().packed().into(),
                headers: allow_cors_with_origin("*"),
            }
        }
    }

    if request.method == "POST" && request.target.starts_with("/pack_action/") {
        Wrapper::with_action_struct(&request.target[13..], PackAction(&request.body))
    } else {
        None
    }
}

/// Handle `/graphql` request
///
/// This handles graphql requests, including fetching the schema.
///
/// * `GET /graphql`: Returns the schema.
/// * `GET /graphql?query=...`: Run query in URL and return JSON result.
/// * `POST /graphql?query=...`: Run query in URL and return JSON result.
/// * `POST /graphql` with `Content-Type = application/graphql`: Run query that's in body and return JSON result.
/// * `POST /graphql` with `Content-Type = application/json`: Body contains a JSON object of the form `{"query"="..."}`. Run query and return JSON result.
pub fn serve_graphql<Query: async_graphql::ObjectType + 'static>(
    request: &HttpRequest,
    query: Query,
) -> Option<HttpReply> {
    let (base, args) = if let Some((b, q)) = request.target.split_once('?') {
        (b, q)
    } else {
        (request.target.as_ref(), "")
    };
    if base != "/graphql" || request.method != "GET" && request.method != "POST" {
        return None;
    }
    block_on(async move {
        let schema = async_graphql::Schema::new(query, EmptyMutation, EmptySubscription);
        if let Some(request) = args.strip_prefix("?query=") {
            let res = schema.execute(request).await;
            Some(HttpReply {
                status: 200,
                contentType: "application/json".into(),
                body: serde_json::to_vec(&res).unwrap().into(),
                headers: allow_cors_with_origin("*"),
            })
        } else if request.method == "GET" {
            Some(HttpReply {
                status: 200,
                contentType: "text".into(), // TODO
                body: schema.sdl().into_bytes().into(),
                headers: allow_cors_with_origin("*"),
            })
        } else if request.contentType == "application/graphql" {
            let res = schema
                .execute(std::str::from_utf8(&request.body.0).unwrap())
                .await;
            Some(HttpReply {
                status: 200,
                contentType: "application/json".into(),
                body: serde_json::to_vec(&res).unwrap().into(),
                headers: allow_cors_with_origin("*"),
            })
        } else {
            let request_result = receive_body(
                Some(&request.contentType),
                request.body.as_ref(),
                Default::default(),
            )
            .await;

            if let Err(err) = &request_result {
                check(false, &format!("err parsing graphql query {}", err));
            }

            let res = schema.execute(request_result.unwrap()).await;
            Some(HttpReply {
                status: 200,
                contentType: "application/json".into(),
                body: serde_json::to_vec(&res).unwrap().into(),
                headers: allow_cors_with_origin("*"),
            })
        }
    })
}

/// Serve GraphiQL UI
///
/// This function serves the GraphiQL UI for `GET /graphiql.html` and `GET /graphiql`.
/// Use with [serve_graphql].
///
/// This wraps [graphiql_source].
pub fn serve_graphiql(request: &HttpRequest) -> Option<HttpReply> {
    if request.method == "GET"
        && (request.target == "/graphiql.html" || request.target == "/graphiql")
    {
        Some(HttpReply {
            status: 200,
            contentType: "text/html".into(),
            body: GraphiQLSource::build().endpoint("/graphql").finish().into(),
            headers: allow_cors_with_origin("*"),
        })
    } else {
        None
    }
}