automaat-server 0.1.0

HTTP API for the Automaat automation utility.
//! A [`Step`] is the grouping of a [`Processor`], some identification details
//! such as a name and description, and the position within a series of steps.
//!
//! A step is one "action" in a series of [`Pipeline`] steps.
//!
//! [`Processor`]: crate::Processor

use crate::resources::Pipeline;
use crate::schema::steps;
use crate::{Database, Processor};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::convert::{AsRef, TryFrom, TryInto};
use std::error::Error;

/// The model representing a step stored in the database.
#[derive(Clone, Debug, Deserialize, Serialize, Associations, Identifiable, Queryable)]
#[belongs_to(Pipeline)]
#[table_name = "steps"]
pub(crate) struct Step {
    pub(crate) id: i32,
    pub(crate) name: String,
    pub(crate) description: Option<String>,
    pub(crate) processor: serde_json::Value,
    pub(crate) position: i32,
    pub(crate) pipeline_id: i32,
}

impl Step {
    pub(crate) fn processor(&self) -> Result<Processor, serde_json::Error> {
        serde_json::from_value(self.processor.clone())
    }

    pub(crate) fn pipeline(&self, conn: &Database) -> QueryResult<Pipeline> {
        use crate::schema::pipelines::dsl::*;

        pipelines.filter(id.eq(self.pipeline_id)).first(&**conn)
    }
}

/// Contains all the details needed to store a step in the database.
///
/// Use [`NewStep::new`] to initialize this struct.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct NewStep<'a> {
    name: &'a str,
    description: Option<&'a str>,
    processor: Processor,
    position: i32,
}

impl<'a> NewStep<'a> {
    /// Initialize a `NewStep` struct, which can be inserted into the
    /// database using the [`NewStep#add_to_pipeline`] method.
    pub(crate) const fn new(
        name: &'a str,
        description: Option<&'a str>,
        processor: Processor,
        position: i32,
    ) -> Self {
        Self {
            name,
            description,
            processor,
            position,
        }
    }

    /// Add a step to a [`Pipeline`], by storing it in the database as an
    /// association.
    ///
    /// Requires a reference to a Pipeline, in order to create the correct data
    /// reference.
    ///
    /// This method can return an error if the database insert failed, or if the
    /// step processor cannot be serialized.
    pub(crate) fn add_to_pipeline(
        self,
        conn: &Database,
        pipeline: &Pipeline,
    ) -> Result<(), Box<dyn Error>> {
        use crate::schema::steps::dsl::*;

        let values = (
            name.eq(&self.name),
            description.eq(&self.description),
            processor.eq(serde_json::to_value(self.processor)?),
            position.eq(self.position),
            pipeline_id.eq(pipeline.id),
        );

        diesel::insert_into(steps)
            .values(values)
            .execute(&**conn)
            .map(|_| ())
            .map_err(Into::into)
    }
}

pub(crate) mod graphql {
    //! All GraphQL related functionality is encapsulated in this module. The
    //! relevant functions and structs are re-exported through
    //! [`crate::graphql`].
    //!
    //! API documentation in this module is also used in the GraphQL API itself
    //! as documentation for the clients.
    //!
    //! You can browse to `/graphql/playground` to see all relevant query,
    //! mutation, and type documentation.

    use super::*;
    use crate::ProcessorInput;
    use juniper::{object, FieldResult, GraphQLInputObject, ID};

    /// Contains all the data needed to create a new `Step`.
    #[derive(Clone, Debug, Deserialize, Serialize, GraphQLInputObject)]
    pub(crate) struct CreateStepInput {
        /// The name of the step.
        ///
        /// Take care to give a short, but descriptive name, to provide maximum
        /// flexibility for the UI, _and_ provide maximum understanding for the
        /// user.
        pub(crate) name: String,

        /// An optional description of the step.
        ///
        /// While the description is optional, it is best-practice to provide
        /// relevant information so that the user knows what to expect when
        /// adding a step to a pipeline.
        pub(crate) description: Option<String>,

        /// The processor used by this step to perform the required action.
        ///
        /// **note**
        ///
        /// Due to the fact that the GraphQL spec does not (yet) support
        /// union-based input types, this is a wrapper type with a separate
        /// field for each processor type supported by the server.
        ///
        /// All fields are nullable, but you MUST provide EXACTLY ONE processor
        /// input type. Providing anything else will result in an error from the
        /// API.
        pub(crate) processor: ProcessorInput,
    }

    #[object(Context = Database)]
    impl Step {
        /// The unique identifier for a specific step.
        fn id() -> ID {
            ID::new(self.id.to_string())
        }

        /// A descriptive name of the step.
        fn name() -> &str {
            &self.name
        }

        /// An (optional) detailed description of the functionality provided by
        /// this step.
        ///
        /// A description _might_ be markdown formatted, and should be parsed
        /// accordingly by the client.
        fn description() -> Option<&str> {
            self.description.as_ref().map(String::as_ref)
        }

        /// The processor type used to run the step.
        ///
        /// This query can fail, if the processor failed to be deserialized.
        fn processor() -> FieldResult<Processor> {
            self.processor().map_err(Into::into)
        }

        /// The position of the step in a pipeline, compared to other steps in
        /// the same pipeline. A lower number means the step runs earlier in the
        /// pipeline.
        fn position() -> i32 {
            self.position
        }

        /// The pipeline to which the step belongs.
        ///
        /// This field can return `null`, but _only_ if a database error
        /// prevents the data from being retrieved.
        ///
        /// Every step is _always_ attached to a pipeline, so in normal
        /// circumstances, this field will always return the relevant pipeline
        /// details.
        ///
        /// If a `null` value is returned, it is up to the client to decide the
        /// best course of action. The following actions are advised, sorted by
        /// preference:
        ///
        /// 1. continue execution if the information is not critical to success,
        /// 2. retry the request to try and get the relevant information,
        /// 3. disable parts of the application reliant on the information,
        /// 4. show a global error, and ask the user to retry.
        fn pipeline(context: &Database) -> FieldResult<Option<Pipeline>> {
            self.pipeline(context).map(Some).map_err(Into::into)
        }
    }
}

#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
impl<'a> TryFrom<(usize, &'a graphql::CreateStepInput)> for NewStep<'a> {
    type Error = String;

    fn try_from((index, input): (usize, &'a graphql::CreateStepInput)) -> Result<Self, String> {
        Ok(Self::new(
            &input.name,
            input.description.as_ref().map(String::as_ref),
            input.processor.clone().try_into()?,
            index as i32,
        ))
    }
}