diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 5d12634847..0000000000 --- a/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -# editorconfig.org -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/openapi-converter/Cargo.lock b/openapi-converter/Cargo.lock index 400705447f..a216eaf9ab 100644 --- a/openapi-converter/Cargo.lock +++ b/openapi-converter/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.70" @@ -24,6 +33,7 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" name = "clients_schema" version = "0.1.0" dependencies = [ + "anyhow", "once_cell", "serde", "serde_ignored", @@ -32,6 +42,26 @@ dependencies = [ "typed-builder", ] +[[package]] +name = "clients_schema_to_openapi" +version = "0.1.0" +dependencies = [ + "anyhow", + "clients_schema", + "convert_case", + "either_n", + "indexmap", + "maplit", + "openapiv3", + "regex", + "serde", + "serde_ignored", + "serde_json", + "serde_path_to_error", + "tracing", + "tracing-subscriber", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -85,6 +115,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -160,6 +202,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89089e897c013b3deb627116ae56a6955a72b8bed395c9526af31c9fe528b484" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa250384981ea14565685dea16a9ccc4d1c541a13f82b9c168572264d1df8c56" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab07dc67230e4a4718e70fd5c20055a4334b121f1f9db8fe63ef39ce9b8c846" + [[package]] name = "ryu" version = "1.0.13" diff --git a/openapi-converter/Cargo.toml b/openapi-converter/Cargo.toml index 608211dedb..9d4d059b69 100644 --- a/openapi-converter/Cargo.toml +++ b/openapi-converter/Cargo.toml @@ -2,4 +2,5 @@ members = [ "clients_schema", "openapi_to_clients_schema", + "clients_schema_to_openapi" ] diff --git a/openapi-converter/clients_schema/Cargo.toml b/openapi-converter/clients_schema/Cargo.toml index 381bbe8bbb..44a38dffb7 100644 --- a/openapi-converter/clients_schema/Cargo.toml +++ b/openapi-converter/clients_schema/Cargo.toml @@ -9,6 +9,7 @@ serde = {version = "1.0", features=["derive"]} serde_json = "1.0" typed-builder = "0.11" once_cell = "1.16" +anyhow = "1.0" [dev-dependencies] serde_path_to_error = "0.1" diff --git a/openapi-converter/clients_schema/src/lib.rs b/openapi-converter/clients_schema/src/lib.rs index 7399869252..ae790cbe33 100644 --- a/openapi-converter/clients_schema/src/lib.rs +++ b/openapi-converter/clients_schema/src/lib.rs @@ -15,6 +15,9 @@ // specific language governing permissions and limitations // under the License. +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use anyhow::bail; // Re-export crates whose types we expose publicly pub use once_cell; @@ -30,7 +33,7 @@ const fn is_false(v: &bool) -> bool { !(*v) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd, Hash)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Hash)] pub struct TypeName { pub namespace: String, pub name: String @@ -44,6 +47,14 @@ impl TypeName { } } } + +impl Display for TypeName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.namespace, self.name) + } +} + + /// /// Type of a value. Used both for property types and nested type definitions. /// @@ -737,6 +748,10 @@ impl Model { pub fn from_reader(r: impl std::io::Read) -> Result { serde_json::from_reader(r) } + + pub fn type_registry(&self) -> TypeRegistry { + TypeRegistry::new(self) + } } #[cfg(test)] @@ -772,3 +787,45 @@ mod tests { }; } } + +pub struct TypeRegistry<'a> { + types: HashMap<&'a TypeName, &'a TypeDefinition> +} + +impl<'a> TypeRegistry<'a> { + pub fn new(model: &Model) -> TypeRegistry { + let types = model.types.iter() + .map(|typedef| (typedef.name(), typedef)) + .collect::>(); + + TypeRegistry{ types } + } + + pub fn get(&self, name: &TypeName) -> anyhow::Result<&'a TypeDefinition> { + match self.types.get(name) { + Some(typedef) => Ok(typedef), + None => bail!("No definition for type {}", name), + } + } + + // pub fn get_interface(&self, name: &TypeName) -> anyhow::Result<&'a Interface> { + // match self.get(name)? { + // TypeDefinition::Interface(itf) => Ok(itf), + // _ => bail!("Type {} is not an interface", name), + // } + // } + + pub fn get_request(&self, name: &TypeName) -> anyhow::Result<&'a Request> { + match self.get(name)? { + TypeDefinition::Request(req) => Ok(req), + _ => bail!("Type {} is not a request", name), + } + } + + pub fn get_resppnse(&self, name: &TypeName) -> anyhow::Result<&'a Response> { + match self.get(name)? { + TypeDefinition::Response(resp) => Ok(resp), + _ => bail!("Type {} is not a response", name), + } + } +} diff --git a/openapi-converter/clients_schema_to_openapi/Cargo.toml b/openapi-converter/clients_schema_to_openapi/Cargo.toml new file mode 100644 index 0000000000..88933866dc --- /dev/null +++ b/openapi-converter/clients_schema_to_openapi/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "clients_schema_to_openapi" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clients_schema = {path="../clients_schema"} + +serde = {version = "1.0", features=["derive"]} +serde_json = "1.0" +serde_path_to_error = "0.1" +serde_ignored = "0.1" +openapiv3 = "1.0" +anyhow = "1.0" +indexmap = "1.9" +convert_case = "0.6" +either_n = "0.2.0" +regex = "1.8" +maplit = "1.0" + +tracing = "0.1.37" +tracing-subscriber = "0.3.16" diff --git a/openapi-converter/clients_schema_to_openapi/src/lib.rs b/openapi-converter/clients_schema_to_openapi/src/lib.rs new file mode 100644 index 0000000000..9f059fc481 --- /dev/null +++ b/openapi-converter/clients_schema_to_openapi/src/lib.rs @@ -0,0 +1,67 @@ +mod paths; +mod schemas; + +use std::io::Write; +use std::path::Path; + +use clients_schema::{Model, Property, TypeDefinition, TypeName, TypeRegistry, ValueOf}; + +pub fn convert_schema(path: impl AsRef, out: impl Write) -> anyhow::Result<()> { + let file = std::fs::File::open(path)?; + + let model: Model = serde_json::from_reader(file)?; + let types = model.type_registry(); + + let mut openapi = openapiv3::OpenAPI::default(); + + openapi.openapi = "3.1.0".into(); + + openapi.info = openapiv3::Info { + title: "Elasticsearch API".to_string(), + description: None, + terms_of_service: None, + contact: None, + license: license(&model), + version: "".to_string(), + extensions: Default::default(), + }; + + // Endpoints + let paths = paths::build_paths(&model.endpoints, &types)?; + + openapi.paths = openapiv3::Paths { + paths: paths, + extensions: Default::default(), + }; + + // Types + let components = openapiv3::Components { + security_schemes: Default::default(), + responses: Default::default(), + parameters: Default::default(), + examples: Default::default(), + request_bodies: Default::default(), + headers: Default::default(), + schemas: Default::default(), + links: Default::default(), + callbacks: Default::default(), + extensions: Default::default(), + }; + + openapi.components = Some(components); + + serde_json::to_writer_pretty(out, &openapi)?; + Ok(()) +} + +fn license(model: &Model) -> Option { + if let Some(info) = &model.info { + Some(openapiv3::License { + name: info.license.name.clone(), + url: Some(info.license.url.clone()), + extensions: Default::default(), + }) + } else { + None + } +} diff --git a/openapi-converter/clients_schema_to_openapi/src/main.rs b/openapi-converter/clients_schema_to_openapi/src/main.rs new file mode 100644 index 0000000000..21935133df --- /dev/null +++ b/openapi-converter/clients_schema_to_openapi/src/main.rs @@ -0,0 +1,7 @@ +fn main() { + clients_schema_to_openapi::convert_schema( + "../../elasticsearch-specification/output/schema/schema.json", + std::io::stdout() + ).unwrap(); + +} diff --git a/openapi-converter/clients_schema_to_openapi/src/paths.rs b/openapi-converter/clients_schema_to_openapi/src/paths.rs new file mode 100644 index 0000000000..d21d0f2315 --- /dev/null +++ b/openapi-converter/clients_schema_to_openapi/src/paths.rs @@ -0,0 +1,154 @@ +use std::collections::{HashMap, HashSet}; +use anyhow::{anyhow, bail}; +use indexmap::IndexMap; +use openapiv3::{ExternalDocumentation, Parameter, ParameterData, ParameterSchemaOrContent, PathItem, PathStyle, QueryStyle, ReferenceOr, Schema}; +use regex::Regex; + +use clients_schema::{Property, TypeDefinition, TypeName, TypeRegistry, ValueOf}; +use crate::schemas; + + +pub fn build_paths(endpoints: &Vec, types: &TypeRegistry) + -> anyhow::Result>> { + let mut paths = IndexMap::new(); + + for endpoint in endpoints { + if endpoint.request.is_none() || endpoint.response.is_none() { + tracing::warn!("Endpoint {} is missing either request or response", &endpoint.name); + continue; + } + + let request = types.get_request(endpoint.request.as_ref().unwrap())?; + + fn find_property<'a>(name: &str, props: &'a Vec) -> anyhow::Result<&'a Property> { + props.iter() + .find(|p| p.name == name) + .ok_or_else(|| anyhow!("Property {} not found in request ", name)) + } + + fn parameter_data(prop: &Property) -> anyhow::Result { + Ok(ParameterData { + name: prop.name.clone(), + description: prop.description.clone(), + required: prop.required, + deprecated: Some(prop.deprecation.is_some()), + format: ParameterSchemaOrContent::Schema(schemas::for_value_of(&prop.typ)?), + example: None, + examples: Default::default(), + explode: None, // Defaults to simple, i.e. comma-separated values for arrays + extensions: Default::default(), + }) + } + + for url_template in &endpoint.urls { + + //----- Path and query parameters + + let mut parameters = Vec::new(); + let mut operation_path = url_template.path.clone(); + + // Path parameters + + for path_variable in get_path_parameters(&operation_path) { + let parameter = Parameter::Path { + parameter_data: parameter_data(find_property(path_variable, &request.path)?)?, + // Simple (the default) maps array to comma-separated values, which is ES behavior + // See https://www.rfc-editor.org/rfc/rfc6570#section-3.2.2 + style: PathStyle::Simple, + }; + + parameters.push(ReferenceOr::Item(parameter)); + } + + // Query parameters + + for query_prop in &request.query { + let parameter = Parameter::Query { + parameter_data: parameter_data(query_prop)?, + allow_reserved: false, + style: QueryStyle::Form, + allow_empty_value: None, + }; + + parameters.push(ReferenceOr::Item(parameter)); + } + + // Add query parameter names to the path template + // See https://www.rfc-editor.org/rfc/rfc6570#section-3.2.8 + if !&request.query.is_empty() { + let params = &request.query.iter().map(|p| p.name.as_str()).collect::>().join(","); + operation_path = format!("{operation_path}{{?{params}}}"); + } + + // Create the operation, it will be repeated if we have several methods + // LATER: we could also register the operation as a component and reference it several times + let operation = openapiv3::Operation { + tags: vec![], + summary: None, + description: Some(endpoint.description.clone()), + external_docs: endpoint.doc_url.as_ref().map(|url| ExternalDocumentation { + description: None, + url: url.clone(), + extensions: Default::default(), + }), + operation_id: None, + parameters: parameters, + request_body: None, // TODO + responses: Default::default(), // TODO + deprecated: endpoint.deprecation.is_some(), + security: None, + servers: vec![], + extensions: Default::default(), // FIXME: translate availability? + }; + + let mut path = PathItem { + summary: None, + description: None, + get: None, + put: None, + post: None, + delete: None, + options: None, + head: None, + patch: None, + trace: None, + servers: vec![], + parameters: vec![], + extensions: Default::default(), + }; + + for method in &url_template.methods { + match method.as_str() { + "HEAD" => path.get = Some(operation.clone()), + "GET" => path.get = Some(operation.clone()), + "POST" => path.post = Some(operation.clone()), + "PUT" => path.put = Some(operation.clone()), + "DELETE" => path.put = Some(operation.clone()), + _ => bail!("Unsupported method: {}", method), + } + } + + paths.insert(operation_path, ReferenceOr::Item(path)); + } + } + + Ok(paths) +} + +fn get_path_parameters (template: &str) -> Vec<&str> { + let regex = Regex::new(r"\{([^}]*)\}").unwrap(); + regex + .find_iter(template) + .map(|m| &template[m.start()+1 .. m.end()-1]) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_path_parameters() { + assert_eq!(get_path_parameters("/{index}/{id}"), vec!{"index", "id"}) + } +} diff --git a/openapi-converter/clients_schema_to_openapi/src/schemas.rs b/openapi-converter/clients_schema_to_openapi/src/schemas.rs new file mode 100644 index 0000000000..0cc7625a30 --- /dev/null +++ b/openapi-converter/clients_schema_to_openapi/src/schemas.rs @@ -0,0 +1,7 @@ +use openapiv3::{ReferenceOr, Schema}; +use clients_schema::ValueOf; + +pub fn for_value_of(value_of: &ValueOf) -> anyhow::Result> { + // TODO + Ok(ReferenceOr::ref_("foo")) +}