schematools 0.22.5

Tools for codegen, preprocessing and validation of json-schema and openapi spec
Documentation
use std::collections::HashMap;

use crate::codegen::openapi::parameters::extract_parameter;
use crate::codegen::openapi::MediaVendorType;
use crate::{
    codegen::jsonschema::{JsonSchemaExtractOptions, ModelContainer},
    error::Error,
    resolver::SchemaResolver,
    scope::SchemaScope,
};
use serde::Serialize;
use serde_json::Map;
use serde_json::Value;

use super::parameters::Parameter;

#[derive(Debug, Serialize, Default, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Responses {
    pub success: Option<Response>,
    pub all: Vec<Response>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Response {
    pub status_code: u32,

    pub models: Option<super::MediaModelsContainer>,

    pub description: Option<String>,

    pub headers: Option<Vec<Parameter>>,
}

pub fn extract(
    node: &Map<String, Value>,
    scope: &mut SchemaScope,
    mcontainer: &mut ModelContainer,
    resolver: &SchemaResolver,
    options: &JsonSchemaExtractOptions,
) -> Result<Responses, Error> {
    match node.get("responses") {
        Some(body) => {
            scope.property("responses");

            let responses = extract_responses(body, scope, mcontainer, resolver, options)?;

            scope.pop();

            Ok(responses)
        }
        None => Ok(Responses::default()),
    }
}

#[allow(clippy::needless_borrow)]
pub fn extract_responses(
    node: &Value,
    scope: &mut SchemaScope,
    mcontainer: &mut ModelContainer,
    resolver: &SchemaResolver,
    options: &JsonSchemaExtractOptions,
) -> Result<Responses, Error> {
    resolver.resolve(node, scope, |node, scope| match node {
        Value::Object(ref data) => {
            let mut responses = Responses::default();

            // parse responses
            let mut parsed = data
                .iter()
                .map(|(status_code, response_node)| {
                    scope.property(status_code);

                    let response = extract_response(
                        status_code,
                        response_node,
                        scope,
                        mcontainer,
                        resolver,
                        options,
                    );

                    scope.pop();

                    response
                })
                .collect::<Result<Vec<Response>, _>>()?;

            // find 2xx and unique models for entire endpoint
            let mut occurrences: HashMap<String, u8> = HashMap::new();
            for response in parsed.iter() {
                if let Some(mcontainer) = &response.models {
                    for mm in &mcontainer.list {
                        occurrences
                            .entry((&mm.model).into())
                            .and_modify(|count| *count += 1)
                            .or_insert(1);
                    }
                }
            }

            let re = regex::Regex::new(r"/vnd\.|\+").unwrap();
            for response in parsed.iter_mut() {
                if let Some(ref mut mcontainer) = response.models {
                    // inidicates if an endpoint has multiple content types
                    mcontainer.multiple_content_types = mcontainer.list.len() > 1;

                    for mm in mcontainer.list.iter_mut() {
                        let key: String = (&mm.model).into();

                        mm.is_unique = *occurrences.get(&key).unwrap_or(&1) == 1;

                        let mut base_content_type = mm.content_type.clone();
                        if let [b, inner, e] = re.split(&mm.content_type).collect::<Vec<_>>()[..] {
                            base_content_type = format!("{b}/{e}");
                            mm.vnd = Some(MediaVendorType {
                                base: base_content_type.clone(),
                                vnd: inner.to_string(),
                            });
                        }

                        if mcontainer.multiple_content_types
                            && base_content_type != mcontainer.default_content_type
                        {
                            mm.alternative_content_type = true;
                        }
                    }
                }
            }

            for response in parsed {
                scope.property(&response.status_code.to_string());

                if responses.success.is_none()
                    && response.status_code >= 200
                    && response.status_code < 300
                {
                    log::info!("{} -> success status code: {}", scope, response.status_code);
                    responses.success = Some(response.clone());
                }

                responses.all.push(response);

                scope.pop();
            }

            Ok(responses)
        }
        _ => Err(Error::CodegenInvalidEndpointProperty(
            "responses".to_string(),
            scope.to_string(),
        )),
    })
}

pub fn extract_response(
    code: &str,
    node: &Value,
    scope: &mut SchemaScope,
    mcontainer: &mut ModelContainer,
    resolver: &SchemaResolver,
    options: &JsonSchemaExtractOptions,
) -> Result<Response, Error> {
    resolver.resolve(node, scope, |node, scope| match node {
        Value::Object(data) => {
            log::trace!("{}", scope);

            let description = data.get("description").map(|v| {
                v.as_str()
                    .map(|s| s.lines().collect::<Vec<_>>().join(" "))
                    .unwrap()
            });

            let status_code = if code == "default" {
                0
            } else {
                code.parse::<u32>().map_err(|_| {
                    Error::CodegenInvalidEndpointProperty(
                        format!("response:{code}"),
                        scope.to_string(),
                    )
                })?
            };

            scope.glue(&status_code.to_string());

            let model = super::get_content(data, scope, mcontainer, resolver, options)
                .map_or(Ok(None), |v| v.map(Some));

            scope.pop();

            let headers = data
                .get("headers")
                .map(|s| match s {
                    Value::Object(headers_map) => {
                        let mut headers: Vec<Parameter> = vec![];

                        for (name, param) in headers_map {
                            headers.push(extract_parameter(
                                &as_header_node(name, param, scope, resolver)?,
                                scope,
                                mcontainer,
                                resolver,
                                options,
                            )?);
                        }

                        Ok(headers)
                    }
                    _ => Err(Error::CodegenInvalidEndpointProperty(
                        format!("response:{code}:headers"),
                        scope.to_string(),
                    )),
                })
                .map_or(Ok(None), |v| v.map(Some))?;

            Ok(Response {
                models: model?,
                headers,
                description,
                status_code,
            })
        }
        _ => Err(Error::CodegenInvalidEndpointProperty(
            format!("response:{code}"),
            scope.to_string(),
        )),
    })
}

fn as_header_node(
    name: &str,
    node: &Value,
    scope: &mut SchemaScope,
    resolver: &SchemaResolver,
) -> Result<Value, Error> {
    resolver.resolve(node, scope, |node, _scope| {
        let mut parameter = node.clone();

        let obj = parameter.as_object_mut().ok_or_else(|| {
            Error::CodegenInvalidEndpointProperty(format!("header:{name}"), "todo".to_string())
        })?;

        obj.insert("in".to_string(), Value::String("header".to_string()));
        obj.insert("name".to_string(), Value::String(name.to_string()));

        Ok(parameter)
    })
}

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

    #[test]
    fn test_all_models_unique() {
        let schema = json!({
            "200": {
                "description": "Success response",
                "content": {
                    "application/json": { "schema" : {"type": "string"} },
                    "application/vnd.short+json": { "schema" : {"type": "object", "properties": { "test" : {"type": "string"}}} },
                },
            },
            "400": {
                "description": "Fail response",
                "content": {
                    "application/json": { "schema" : {"type": "object", "properties": { "errorCode" : {"type": "number"}}} },
                },
            }
        });

        let mut mcontainer = ModelContainer::default();
        let mut scope = SchemaScope::default();
        let resolver = SchemaResolver::empty();
        let options = JsonSchemaExtractOptions::default();

        let result = extract_responses(&schema, &mut scope, &mut mcontainer, &resolver, &options);

        assert!(result.is_ok());

        let responses = result.unwrap();
        assert!(!responses.all.is_empty());

        {
            let mut it = responses.all.iter();
            let response200 = it.next().unwrap();
            let m1 = response200.models.as_ref().unwrap().list.get(0).unwrap();
            assert_eq!(m1.alternative_content_type, false);

            let m2 = response200.models.as_ref().unwrap().list.get(1).unwrap();
            assert_eq!(m2.alternative_content_type, false);
            assert_eq!(
                m2.vnd,
                Some(MediaVendorType {
                    base: "application/json".to_string(),
                    vnd: "short".to_string()
                })
            );

            let response400 = it.next().unwrap();
            let m1 = response400.models.as_ref().unwrap().list.get(0).unwrap();
            assert_eq!(m1.alternative_content_type, false);
        }

        for response in responses.all {
            let mcontainer = response.models.unwrap();
            assert!(!mcontainer.list.is_empty());

            for m in mcontainer.list {
                assert!(m.is_unique, "{:?} should be unique", m.model);
            }
        }
    }

    #[test]
    fn test_alternative() {
        let schema = json!({
            "200": {
                "description": "Success response",
                "content": {
                    "application/json": { "schema" : {"type": "string"} },
                    "text/html": { "schema" : {"type": "string"} },
                },
            }
        });

        let mut mcontainer = ModelContainer::default();
        let mut scope = SchemaScope::default();
        let resolver = SchemaResolver::empty();
        let options = JsonSchemaExtractOptions::default();

        let result = extract_responses(&schema, &mut scope, &mut mcontainer, &resolver, &options);

        assert!(result.is_ok());

        let responses = result.unwrap();
        assert!(!responses.all.is_empty());

        let response = responses.all.iter().next().unwrap();
        let models = response.models.as_ref().unwrap();

        let mut it = models.list.iter();
        let first = it.next().unwrap();
        assert_eq!(first.alternative_content_type, false);

        let second = it.next().unwrap();
        assert_eq!(second.alternative_content_type, true);
    }

    #[test]
    fn test_no_unique_model() {
        let schema = json!({
            "200": {
                "description": "Success response",
                "content": {
                    "application/json": { "schema" : {"type": "string"} },
                    "application/vnd.short+json": { "schema" : {"type": "string"} },
                },
            },
            "400": {
                "description": "Fail response",
                "content": {
                    "application/json": { "schema" : {"type": "string"} },
                },
            }
        });

        let mut mcontainer = ModelContainer::default();
        let mut scope = SchemaScope::default();
        let resolver = SchemaResolver::empty();
        let options = JsonSchemaExtractOptions::default();

        let result = extract_responses(&schema, &mut scope, &mut mcontainer, &resolver, &options);

        assert!(result.is_ok());

        let responses = result.unwrap();
        assert!(!responses.all.is_empty());

        for response in responses.all {
            let mcontainer = response.models.unwrap();

            assert!(!mcontainer.list.is_empty());

            for m in mcontainer.list {
                assert!(!m.is_unique, "{:?} should not be unique", m.model);
            }
        }
    }
}