httpgenerator-openapi 0.1.1

OpenAPI loading, inspection, and normalization for HTTP File Generator
Documentation
use crate::{
    OpenApiContentFormat, OpenApiDocumentLoadError, OpenApiSource, OpenApiSpecificationVersion,
    RawOpenApiDocument, TypedOpenApiDocument, TypedOpenApiParseError, load_raw_document,
    load_raw_document_from_source, parse_typed_document,
};

pub enum LoadedOpenApiDocument {
    Swagger2 {
        raw: RawOpenApiDocument,
    },
    OpenApi30 {
        raw: RawOpenApiDocument,
        document: openapiv3::OpenAPI,
    },
    OpenApi31 {
        raw: RawOpenApiDocument,
        document: openapiv3_1::OpenApi,
    },
    OpenApi31Raw {
        raw: RawOpenApiDocument,
    },
}

impl LoadedOpenApiDocument {
    pub fn raw(&self) -> &RawOpenApiDocument {
        match self {
            Self::Swagger2 { raw }
            | Self::OpenApi30 { raw, .. }
            | Self::OpenApi31 { raw, .. }
            | Self::OpenApi31Raw { raw } => raw,
        }
    }

    pub fn source(&self) -> &OpenApiSource {
        self.raw().source()
    }

    pub fn format(&self) -> OpenApiContentFormat {
        self.raw().format()
    }

    pub fn specification_version(&self) -> OpenApiSpecificationVersion {
        match self {
            Self::Swagger2 { .. } => OpenApiSpecificationVersion::Swagger2,
            Self::OpenApi30 { .. } => OpenApiSpecificationVersion::OpenApi30,
            Self::OpenApi31 { .. } | Self::OpenApi31Raw { .. } => {
                OpenApiSpecificationVersion::OpenApi31
            }
        }
    }

    pub fn as_openapi30(&self) -> Option<&openapiv3::OpenAPI> {
        match self {
            Self::Swagger2 { .. } | Self::OpenApi31 { .. } | Self::OpenApi31Raw { .. } => {
                None
            }
            Self::OpenApi30 { document, .. } => Some(document),
        }
    }

    pub fn as_openapi31(&self) -> Option<&openapiv3_1::OpenApi> {
        match self {
            Self::Swagger2 { .. } | Self::OpenApi30 { .. } | Self::OpenApi31Raw { .. } => {
                None
            }
            Self::OpenApi31 { document, .. } => Some(document),
        }
    }
}

pub fn load_document(input: &str) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
    load_document_with_options(input, false)
}

pub(crate) fn load_document_with_options(
    input: &str,
    tolerate_invalid_openapi31: bool,
) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
    let raw = load_raw_document(input).map_err(OpenApiDocumentLoadError::RawLoad)?;
    load_document_from_raw_with_options(raw, tolerate_invalid_openapi31)
}

pub fn load_document_from_source(
    source: OpenApiSource,
) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
    load_document_from_source_with_options(source, false)
}

pub(crate) fn load_document_from_source_with_options(
    source: OpenApiSource,
    tolerate_invalid_openapi31: bool,
) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
    let raw = load_raw_document_from_source(source).map_err(OpenApiDocumentLoadError::RawLoad)?;
    load_document_from_raw_with_options(raw, tolerate_invalid_openapi31)
}

pub fn load_document_from_raw(
    raw: RawOpenApiDocument,
) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
    load_document_from_raw_with_options(raw, false)
}

pub(crate) fn load_document_from_raw_with_options(
    raw: RawOpenApiDocument,
    tolerate_invalid_openapi31: bool,
) -> Result<LoadedOpenApiDocument, OpenApiDocumentLoadError> {
    if matches!(
        raw.specification_version(),
        Ok(OpenApiSpecificationVersion::Swagger2)
    ) {
        return Ok(LoadedOpenApiDocument::Swagger2 { raw });
    }

    match parse_typed_document(&raw) {
        Ok(TypedOpenApiDocument::OpenApi30(document)) => {
            Ok(LoadedOpenApiDocument::OpenApi30 { raw, document })
        }
        Ok(TypedOpenApiDocument::OpenApi31(document)) => {
            Ok(LoadedOpenApiDocument::OpenApi31 { raw, document })
        }
        Err(TypedOpenApiParseError::Deserialize {
            version: OpenApiSpecificationVersion::OpenApi31,
            ..
        }) if should_fallback_to_raw_openapi31(&raw, tolerate_invalid_openapi31) => {
            Ok(LoadedOpenApiDocument::OpenApi31Raw { raw })
        }
        Err(error) => Err(OpenApiDocumentLoadError::TypedParse(error)),
    }
}

fn should_fallback_to_raw_openapi31(
    raw: &RawOpenApiDocument,
    tolerate_invalid_openapi31: bool,
) -> bool {
    matches!(
        raw.specification_version(),
        Ok(OpenApiSpecificationVersion::OpenApi31)
    ) && (is_webhook_only_openapi31_document(raw) || tolerate_invalid_openapi31)
}

fn is_webhook_only_openapi31_document(raw: &RawOpenApiDocument) -> bool {
    matches!(
        raw.specification_version(),
        Ok(OpenApiSpecificationVersion::OpenApi31)
    ) && raw.value().get("paths").is_none()
        && raw
            .value()
            .get("webhooks")
            .and_then(serde_json::Value::as_object)
            .is_some()
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::{Path, PathBuf},
        sync::atomic::{AtomicU64, Ordering},
    };

    use crate::{OpenApiSource, OpenApiSpecificationVersion, decode_raw_document};

    use super::{
        LoadedOpenApiDocument, load_document, load_document_from_raw,
        load_document_from_raw_with_options, load_document_from_source,
    };

    static TEST_ARTIFACT_ID: AtomicU64 = AtomicU64::new(0);

    #[test]
    fn loads_openapi_thirty_documents_from_raw_input() {
        let raw = decode_raw_document(
            OpenApiSource::Path(PathBuf::from("openapi.json")),
            r#"{
                "openapi": "3.0.2",
                "info": { "title": "Example", "version": "1.0.0" },
                "paths": {}
            }"#,
        )
        .unwrap();

        let loaded = load_document_from_raw(raw).unwrap();

        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi30 { .. }));
        assert_eq!(
            loaded.specification_version(),
            OpenApiSpecificationVersion::OpenApi30
        );
        assert!(loaded.as_openapi30().is_some());
        assert!(loaded.as_openapi31().is_none());
    }

    #[test]
    fn loads_openapi_thirty_one_documents_from_a_source() {
        let file = TestFile::new(
            "openapi.yaml",
            "openapi: 3.1.0\ninfo:\n  title: Example\n  version: 1.0.0\npaths: {}\n",
        );

        let loaded =
            load_document_from_source(OpenApiSource::Path(file.path().to_path_buf())).unwrap();

        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi31 { .. }));
        assert_eq!(
            loaded.specification_version(),
            OpenApiSpecificationVersion::OpenApi31
        );
        assert_eq!(
            loaded.source(),
            &OpenApiSource::Path(file.path().to_path_buf())
        );
    }

    #[test]
    fn loads_webhook_only_openapi_thirty_one_documents_with_a_raw_fallback() {
        let raw = decode_raw_document(
            OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.1/webhook-example.json")),
            include_str!("../../../../test/OpenAPI/v3.1/webhook-example.json"),
        )
        .unwrap();

        let loaded = load_document_from_raw(raw).unwrap();

        assert!(matches!(
            loaded,
            LoadedOpenApiDocument::OpenApi31Raw { .. }
        ));
        assert_eq!(
            loaded.specification_version(),
            OpenApiSpecificationVersion::OpenApi31
        );
        assert!(loaded.as_openapi31().is_none());
    }

    #[test]
    fn tolerant_loader_accepts_invalid_openapi_thirty_one_documents() {
        let raw = decode_raw_document(
            OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.1/non-oauth-scopes.json")),
            include_str!("../../../../test/OpenAPI/v3.1/non-oauth-scopes.json"),
        )
        .unwrap();

        let loaded = load_document_from_raw_with_options(raw, true).unwrap();

        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi31Raw { .. }));
        assert_eq!(
            loaded.specification_version(),
            OpenApiSpecificationVersion::OpenApi31
        );
        assert!(loaded.as_openapi31().is_none());
    }

    #[test]
    fn loads_swagger_two_documents_with_a_raw_bridge() {
        let raw = decode_raw_document(
            OpenApiSource::Path(PathBuf::from("swagger.json")),
            r#"{
                "swagger": "2.0",
                "info": { "title": "Example", "version": "1.0.0" },
                "paths": {}
            }"#,
        )
        .unwrap();

        let loaded = load_document_from_raw(raw).unwrap();

        assert!(matches!(loaded, LoadedOpenApiDocument::Swagger2 { .. }));
        assert_eq!(
            loaded.specification_version(),
            OpenApiSpecificationVersion::Swagger2
        );
        assert!(loaded.as_openapi30().is_none());
        assert!(loaded.as_openapi31().is_none());
    }

    #[test]
    fn load_document_reads_and_parses_local_files() {
        let file = TestFile::new(
            "openapi.json",
            r#"{
                "openapi": "3.0.2",
                "info": { "title": "Example", "version": "1.0.0" },
                "paths": {}
            }"#,
        );

        let loaded = load_document(file.path().to_str().unwrap()).unwrap();

        assert!(matches!(loaded, LoadedOpenApiDocument::OpenApi30 { .. }));
        assert_eq!(loaded.format(), crate::OpenApiContentFormat::Json);
    }

    fn unique_test_path(file_name: &str) -> PathBuf {
        let artifact_id = TEST_ARTIFACT_ID.fetch_add(1, Ordering::Relaxed);
        let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("target")
            .join("test-data");

        fs::create_dir_all(&directory).unwrap();

        directory.join(format!(
            "loader-{}-{}-{file_name}",
            std::process::id(),
            artifact_id
        ))
    }

    struct TestFile {
        path: PathBuf,
    }

    impl TestFile {
        fn new(file_name: &str, content: &str) -> Self {
            let path = unique_test_path(file_name);
            fs::write(&path, content).unwrap();
            Self { path }
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TestFile {
        fn drop(&mut self) {
            let _ = fs::remove_file(&self.path);
        }
    }
}