auric-runtime 0.1.6

Runtime for the Ember-inspired Auric SPA framework
Documentation
use super::super::Adapter;
use crate::Config;
use crate::data::QueryRecordOptions;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use jsonapi_core::Resource;
use pluralizer::pluralize;
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;

#[derive(Default)]
pub struct RestAdapter {
    api_url: String,
}

impl RestAdapter {
    pub fn new(api_url: String) -> Self {
        Self { api_url }
    }
}

#[async_trait(?Send)]
impl Adapter for RestAdapter {
    fn init(&mut self, config: &Config) -> Result<()> {
        let config = config.as_object().expect("Expected config to be an object");
        let api_url = match config.get("api_url") {
            None => return Err(anyhow!("Configuration requires an api_url field")),
            Some(api_url) => match api_url.as_str() {
                None => return Err(anyhow!("Expected api_url config field to be a string")),
                Some(api_url) => api_url.to_owned(),
            },
        };
        self.api_url = api_url;
        Ok(())
    }

    async fn create_record(&self, resource: Resource) -> Result<Resource> {
        // Perform request
        let url = format!("{}/{}", self.api_url, resource.type_);
        let client = Client::new();
        let res = client.post(url).json(&resource.attributes).send().await?;

        // Read response
        let res_body: HashMap<String, Value> = res.json().await?;
        let model_name = pluralize(&resource.type_, 1, false);
        let data = match res_body.get(&model_name) {
            None => return Err(anyhow!("resource not found")),
            Some(data) => data,
        };
        let id = match data.as_object() {
            None => return Err(anyhow!("expected an object")),
            Some(map) => match map.get("id") {
                None => return Err(anyhow!("expected returned object to include an id")),
                Some(id) => id.as_str().unwrap_or_default().to_string(),
            },
        };
        let finalized_resource = Resource {
            type_: resource.type_,
            id: Some(id),
            lid: None,
            attributes: data.clone(),
            relationships: Default::default(),
            links: None,
            meta: None,
        };
        Ok(finalized_resource)
    }

    async fn delete_record(&self, resource: &Resource) -> Result<()> {
        // Perform request
        let url = format!(
            "{}/{}/{}",
            self.api_url,
            resource.type_,
            resource.id.clone().unwrap_or_default()
        );
        let client = Client::new();
        let _res = client.delete(url).send().await?;
        Ok(())
    }

    async fn find_record(&self, resource_type: &str, id: &str) -> Result<Resource> {
        let resource_type = resource_type.to_string();
        let id = id.to_string();

        // Perform request
        let url = format!("{}/{resource_type}/{id}", self.api_url);
        let client = Client::new();
        let res = client.get(url).send().await?;

        // Read response
        let res_body: HashMap<String, Value> = res.json().await?;
        let model_name = pluralize(&resource_type, 1, false);
        let data = match res_body.get(&model_name) {
            None => return Err(anyhow!("resource not found")),
            Some(data) => data,
        };
        let resource = Resource {
            type_: resource_type,
            id: Some(id),
            lid: None,
            attributes: data.clone(),
            relationships: Default::default(),
            links: None,
            meta: None,
        };
        Ok(resource)
    }

    async fn query(&self, resource_type: &str, _query: Value) -> Result<Vec<Resource>> {
        // Perform request
        let url = format!("{}/{resource_type}", self.api_url);
        let client = Client::new();
        let res = client.get(url).send().await?;

        // Read response
        let res_body: HashMap<String, Value> = res.json().await?;
        let array: Vec<Value> = match res_body.get(resource_type) {
            None => return Err(anyhow!("resource not found")),
            Some(data) => match data.as_array() {
                None => return Err(anyhow!("expected an array of resources")),
                Some(array) => array.clone(),
            },
        };
        let mut resources = vec![];
        for object in array {
            let id = match object.get("id") {
                None => return Err(anyhow!("resources require an id")),
                Some(id) => match id.as_str() {
                    None => return Err(anyhow!("resources require id to be a string")),
                    Some(id) => id.to_string(),
                },
            };
            let resource = Resource {
                type_: resource_type.to_string(),
                id: Some(id),
                lid: None,
                attributes: object.clone(),
                relationships: Default::default(),
                links: None,
                meta: None,
            };
            resources.push(resource);
        }
        Ok(resources)
    }

    async fn query_record(
        &self,
        _resource_type: &str,
        _query: Value,
        _options: QueryRecordOptions,
    ) -> Result<Option<Resource>> {
        todo!()
    }

    async fn update_record(&self, resource: Resource) -> Result<Resource> {
        // Perform request
        let id = resource.id.clone().unwrap_or_default();
        let url = format!("{}/{}/{}", self.api_url, resource.type_, id);
        let client = Client::new();
        let res = client.put(url).send().await?;

        // Read response
        let res_body: HashMap<String, Value> = res.json().await?;
        let model_name = pluralize(&resource.type_, 1, false);
        let data = match res_body.get(&model_name) {
            None => return Err(anyhow!("resource not found")),
            Some(data) => data,
        };
        let finalized_resource = Resource {
            type_: resource.type_,
            id: Some(resource.id.unwrap_or_default().to_string()),
            lid: None,
            attributes: data.clone(),
            relationships: Default::default(),
            links: None,
            meta: None,
        };
        Ok(finalized_resource)
    }
}

#[cfg(test)]
mod tests {
    use super::RestAdapter;
    use crate::data::Adapter;
    use httptest::matchers::request;
    use httptest::responders::{json_encoded, status_code};
    use httptest::{Expectation, Server};
    use jsonapi_core::Resource;
    use serde_json::json;

    #[tokio::test]
    async fn can_create_record() {
        // Setup mock server and expections for a posted Customer record
        let server = Server::run();
        server.expect(
            Expectation::matching(request::method_path("POST", "/v1/customers"))
                .respond_with(json_encoded(json!({"customer": {"id": "123", "name": "Acme Widgets"}}))),
        );

        // Perform test
        let customer = Resource {
            type_: "customers".to_string(),
            id: None,
            lid: None,
            attributes: json!({"name": "Acme Widgets"}),
            relationships: Default::default(),
            links: None,
            meta: None,
        };
        let adapter = RestAdapter::new(server.url_str("/v1"));
        let finalized_customer = adapter
            .create_record(customer)
            .await
            .expect("Expected to create customer record");
        assert_eq!(finalized_customer.id, Some("123".to_string()));
        assert!(finalized_customer.attributes.to_string().contains("Acme Widgets"))
    }

    #[tokio::test]
    async fn can_delete_record() {
        // Setup mock server and expections for a delete Customer operation
        let server = Server::run();
        server.expect(
            Expectation::matching(request::method_path("DELETE", "/v1/customers/123")).respond_with(status_code(200)),
        );

        // Perform test
        let customer = Resource {
            type_: "customers".to_string(),
            id: Some("123".to_string()),
            lid: None,
            attributes: json!({}),
            relationships: Default::default(),
            links: None,
            meta: None,
        };
        let adapter = RestAdapter::new(server.url_str("/v1"));
        adapter
            .delete_record(&customer)
            .await
            .expect("Expected to delete customer record");
    }

    #[tokio::test]
    async fn can_find_record() {
        // Setup mock server and expections for a delete Customer operation
        let server = Server::run();
        server.expect(
            Expectation::matching(request::method_path("GET", "/v1/customers/123"))
                .respond_with(json_encoded(json!({"customer": {"id": "123", "name": "Acme Widgets"}}))),
        );

        // Perform test
        let adapter = RestAdapter::new(server.url_str("/v1"));
        let customer = adapter
            .find_record("customers", "123")
            .await
            .expect("Expected to find customer record");
        assert_eq!(customer.id, Some("123".to_string()));
    }

    #[tokio::test]
    async fn can_query() {
        // Setup mock server and expections for a get customers operation
        let server = Server::run();
        server.expect(
            Expectation::matching(request::method_path("GET", "/v1/customers")).respond_with(json_encoded(
                json!({"customers": [
                    {"id": "123", "name": "Acme Widgets"},
                    {"id": "456", "name": "Standard Paper Supplies, Inc."},
                ]}),
            )),
        );

        // Perform test
        let adapter = RestAdapter::new(server.url_str("/v1"));
        let customers = adapter
            .query("customers", json!({}))
            .await
            .expect("Expected to find customer records");
        assert_eq!(customers.len(), 2);
        let first = customers.first().expect("Expected to find first customer");
        assert_eq!(first.id, Some("123".to_string()));
        let second = customers.get(1).expect("Expected to find second customer");
        assert_eq!(second.id, Some("456".to_string()));
    }

    #[tokio::test]
    async fn can_update_record() {
        // Setup mock server and expections for a put-ted Customer record
        let server = Server::run();
        server.expect(
            Expectation::matching(request::method_path("PUT", "/v1/customers/123")).respond_with(json_encoded(
                json!({"customer": {"id": "123", "name": "Acme Widgets 2"}}),
            )),
        );

        // Perform test
        let customer = Resource {
            type_: "customers".to_string(),
            id: Some("123".to_string()),
            lid: None,
            attributes: json!({"name": "Acme Widgets"}),
            relationships: Default::default(),
            links: None,
            meta: None,
        };
        let adapter = RestAdapter::new(server.url_str("/v1"));
        let finalized_customer = adapter
            .update_record(customer)
            .await
            .expect("Expected to update customer record");
        assert_eq!(finalized_customer.id, Some("123".to_string()));
        assert!(finalized_customer.attributes.to_string().contains("Acme Widgets 2"))
    }
}