From e18380f99f3b6d84f2f8342485c7053ddd0afaaa Mon Sep 17 00:00:00 2001 From: Kurt Wolf Date: Fri, 12 Apr 2024 00:50:35 -0400 Subject: [PATCH] fairly significant refactor. aimed at supporting deepl --- Cargo.lock | 13 +- Justfile | 17 +- core/Cargo.toml | 4 +- core/Justfile | 12 + core/src/child_schemas.rs | 234 +- core/src/extractor.rs | 441 ++-- core/src/extractor/operation.rs | 265 +++ core/src/extractor/record.rs | 304 ++- core/src/extractor/resolution.rs | 113 - core/src/extractor/ty.rs | 123 + core/src/lib.rs | 6 +- core/src/sanitize.rs | 36 + core/src/util.rs | 58 + core/tests/test_extractor.rs | 39 + hir/src/lib.rs | 116 +- libninja/Cargo.toml | 2 +- libninja/Justfile | 13 + libninja/src/command/meta.rs | 21 +- libninja/src/lib.rs | 42 +- libninja/src/rust/codegen.rs | 14 +- libninja/src/rust/codegen/example.rs | 115 +- libninja/src/rust/codegen/{typ.rs => ty.rs} | 22 +- libninja/src/rust/lower_hir.rs | 36 +- libninja/tests/all_of/main.rs | 67 +- libninja/tests/basic/main.rs | 61 +- libninja/tests/regression/main.rs | 35 +- mir/Cargo.toml | 3 +- mir/src/ident.rs | 18 +- mir/src/ty.rs | 15 +- mir_rust/src/lib.rs | 19 +- .../tests/spec => test_specs}/basic.yaml | 0 test_specs/deepl.yaml | 1981 +++++++++++++++++ .../tests/spec => test_specs}/recurly.yaml | 0 33 files changed, 3355 insertions(+), 890 deletions(-) create mode 100644 core/Justfile create mode 100644 core/src/extractor/operation.rs delete mode 100644 core/src/extractor/resolution.rs create mode 100644 core/src/extractor/ty.rs create mode 100644 core/src/sanitize.rs create mode 100644 core/src/util.rs create mode 100644 core/tests/test_extractor.rs create mode 100644 libninja/Justfile rename libninja/src/rust/codegen/{typ.rs => ty.rs} (88%) rename {libninja/tests/spec => test_specs}/basic.yaml (100%) create mode 100644 test_specs/deepl.yaml rename {libninja/tests/spec => test_specs}/recurly.yaml (100%) diff --git a/Cargo.lock b/Cargo.lock index 4c1a6b2..d53252d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -484,9 +484,9 @@ checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] name = "http" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -681,6 +681,7 @@ dependencies = [ "anyhow", "clap", "convert_case", + "http", "include_dir", "indexmap", "libninja_hir", @@ -688,6 +689,7 @@ dependencies = [ "openapiv3-extended", "proc-macro2", "quote", + "regex-lite", "serde", "serde_json", "serde_yaml", @@ -723,6 +725,7 @@ dependencies = [ name = "libninja_mir" version = "0.1.0" dependencies = [ + "openapiv3-extended", "proc-macro2", "quote", ] @@ -1084,6 +1087,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b661b2f27137bdbc16f00eda72866a92bb28af1753ffbd56744fb6e2e9cd8e" + [[package]] name = "regex-syntax" version = "0.8.2" diff --git a/Justfile b/Justfile index 4a4864e..dbf3261 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,6 @@ set positional-arguments -set dotenv-load := true +set dotenv-load +set export help: @just --list --unsorted @@ -81,9 +82,6 @@ go: checkexec ${CARGO_TARGET_DIR:-target}/debug/ocg $(fd . ocg/template) -- cargo clean --package ocg cargo run -- gen --name PetStore --output-dir gen/petstore-go --generator go spec/petstore.yaml --github libninjacom/petstore-go --version 0.1.0 -create: - bash ocg/script/create.sh - generate: #!/bin/bash -euxo pipefail if [ -n "${LIBRARY:-}" ]; then @@ -97,7 +95,7 @@ generate: test *ARGS: checkexec commercial -- just dummy_commercial - cargo test + cargo test -- "$ARGS" alias t := test integration *ARGS: @@ -105,14 +103,14 @@ integration *ARGS: alias int := integration # Test the library we just generated -test-lib: +test_lib: #!/bin/bash -euxo pipefail REPO_DIR=$DIR/$(basename $REPO) cd $REPO_DIR just bootstrap just check just test -alias tt := test-lib +alias tt := test_lib clean-gen: #!/bin/bash -euxo pipefail @@ -125,9 +123,6 @@ clean-gen: delete *ARG: gh repo delete $REPO {{ARG}} -push: - bash ocg/script/push.sh - commercial: rm -rf commercial git clone https://github.com/kurtbuilds/libninja-commercial commercial @@ -135,4 +130,4 @@ commercial: # Create a dummy commercial repo that lets the workspace work # without the commericial code dummy_commercial: - cargo new --lib commercial --name libninja_commercial \ No newline at end of file + cargo new --lib commercial --name libninja_commercial diff --git a/core/Cargo.toml b/core/Cargo.toml index a759e99..2abebc4 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] -name = "ln_core" +name = "libninja_core" [dependencies] anyhow = "1.0.71" @@ -24,6 +24,8 @@ tera = "1.19.0" libninja_hir = { path = "../hir" } tracing = "0.1.40" tracing-ez = "0.3.0" +regex-lite = "0.1.5" [dev-dependencies] serde_yaml = "0.9.25" +http = "1.1.0" diff --git a/core/Justfile b/core/Justfile new file mode 100644 index 0000000..1befe41 --- /dev/null +++ b/core/Justfile @@ -0,0 +1,12 @@ + +run: + cargo run + +test *ARGS: + cargo test -- $(ARGS) + +build: + cargo build + +install: + cargo install --path . diff --git a/core/src/child_schemas.rs b/core/src/child_schemas.rs index d959ebf..08b01e3 100644 --- a/core/src/child_schemas.rs +++ b/core/src/child_schemas.rs @@ -1,107 +1,137 @@ -use std::collections::HashMap; -use std::sync::atomic::AtomicBool; -use openapiv3::{OpenAPI, Operation, RequestBody, Response, Schema, SchemaKind, Type}; +// pub trait ChildSchemas { +// fn add_child_schemas<'a>(&'a self, acc: &mut HashMap); +// } -pub trait ChildSchemas { - fn add_child_schemas<'a>(&'a self, acc: &mut HashMap); -} +// impl ChildSchemas for Schema { +// fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { +// match &self.kind { +// SchemaKind::Type(Type::Array(a)) => { +// let Some(items) = &a.items else { +// return; +// }; +// let Some(item) = items.as_item() else { +// return; +// }; +// if let Some(title) = &item.title { +// let title = sanitize(title).to_string(); +// acc.entry(title).or_insert(item); +// } +// item.add_child_schemas(acc); +// } +// SchemaKind::Type(Type::Object(o)) => { +// if let Some(title) = &self.title { +// let title = sanitize(title).to_string(); +// acc.entry(title).or_insert(self); +// } +// for (_name, prop) in &o.properties { +// let Some(prop) = prop.as_item() else { +// continue; +// }; +// if let Some(title) = &prop.title { +// let title = sanitize(title).to_string(); +// acc.entry(title).or_insert(prop); +// } +// prop.add_child_schemas(acc); +// } +// } +// SchemaKind::Type(_) => {} +// SchemaKind::OneOf { one_of: schemas } +// | SchemaKind::AllOf { all_of: schemas } +// | SchemaKind::AnyOf { any_of: schemas } => { +// for schema in schemas { +// let Some(schema) = schema.as_item() else { +// continue; +// }; +// if let Some(title) = &schema.title { +// let title = sanitize(title).to_string(); +// acc.entry(title).or_insert(schema); +// } +// schema.add_child_schemas(acc); +// } +// } +// SchemaKind::Not { .. } => {} +// SchemaKind::Any(_) => {} +// } +// } +// } -impl ChildSchemas for Schema { - fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { - match &self.kind { - SchemaKind::Type(Type::Array(a)) => { - let Some(items) = &a.items else { return; }; - let Some(item) = items.as_item() else { return; }; - if let Some(title) = &item.title { - acc.entry(title.clone()).or_insert(item); - } - item.add_child_schemas(acc); - } - SchemaKind::Type(Type::Object(o)) => { - if let Some(title) = &self.title { - acc.entry(title.clone()).or_insert(self); - } - for (_name, prop) in &o.properties { - let Some(prop) = prop.as_item() else { continue; }; - if let Some(title) = &prop.title { - acc.entry(title.clone()).or_insert(prop); - } - prop.add_child_schemas(acc); - } - } - SchemaKind::Type(_) => {} - | SchemaKind::OneOf { one_of: schemas } - | SchemaKind::AllOf { all_of: schemas } - | SchemaKind::AnyOf { any_of: schemas} => { - for schema in schemas { - let Some(schema) = schema.as_item() else { continue; }; - if let Some(title) = &schema.title { - acc.entry(title.clone()).or_insert(schema); - } - schema.add_child_schemas(acc); - } - } - SchemaKind::Not { .. } => {} - SchemaKind::Any(_) => {} - } - } -} +// impl ChildSchemas for Operation { +// fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { +// 'body: { +// let Some(body) = &self.request_body else { +// break 'body; +// }; +// let Some(body) = body.as_item() else { +// break 'body; +// }; +// body.add_child_schemas(acc); +// } +// for par in &self.parameters { +// let Some(par) = par.as_item() else { +// continue; +// }; +// let Some(schema) = par.data.schema() else { +// continue; +// }; +// let Some(schema) = schema.as_item() else { +// continue; +// }; +// schema.add_child_schemas(acc); +// } +// for (_code, response) in &self.responses.responses { +// let Some(response) = response.as_item() else { +// continue; +// }; +// response.add_child_schemas(acc); +// } +// } +// } -impl ChildSchemas for Operation { - fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { - 'body: { - let Some(body) = &self.request_body else { break 'body; }; - let Some(body) = body.as_item() else { break 'body; }; - body.add_child_schemas(acc); - } - for par in &self.parameters { - let Some(par) = par.as_item() else { continue; }; - let Some(schema) = par.data.schema() else { continue; }; - let Some(schema) = schema.as_item() else { continue; }; - schema.add_child_schemas(acc); - } - for (_code, response) in &self.responses.responses { - let Some(response) = response.as_item() else { continue; }; - response.add_child_schemas(acc); - } - } -} +// impl ChildSchemas for RequestBody { +// fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { +// for (_key, content) in &self.content { +// let Some(schema) = &content.schema else { +// continue; +// }; +// let Some(schema) = schema.as_item() else { +// continue; +// }; +// if let Some(title) = &schema.title { +// acc.entry(title.clone()).or_insert(schema); +// } +// schema.add_child_schemas(acc); +// } +// } +// } -impl ChildSchemas for RequestBody { - fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { - for (_key, content) in &self.content { - let Some(schema) = &content.schema else { continue; }; - let Some(schema) = schema.as_item() else { continue; }; - if let Some(title) = &schema.title { - acc.entry(title.clone()).or_insert(schema); - } - schema.add_child_schemas(acc); - } - } -} +// impl ChildSchemas for Response { +// fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { +// for (k, content) in &self.content { +// let Some(schema) = &content.schema else { +// continue; +// }; +// let Some(schema) = schema.as_item() else { +// continue; +// }; +// if let Some(title) = &schema.title { +// acc.entry(title.clone()).or_insert(schema); +// } +// schema.add_child_schemas(acc); +// } +// } +// } -impl ChildSchemas for Response { - fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { - for (k, content) in &self.content { - let Some(schema) = &content.schema else { continue; }; - let Some(schema) = schema.as_item() else { continue; }; - if let Some(title) = &schema.title { - acc.entry(title.clone()).or_insert(schema); - } - schema.add_child_schemas(acc); - } - } -} - -impl ChildSchemas for OpenAPI { - fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { - for (_path, _method, op, _item) in self.operations() { - op.add_child_schemas(acc); - } - for (name, schema) in &self.schemas { - let Some(schema) = schema.as_item() else { continue; }; - acc.entry(name.clone()).or_insert(schema); - schema.add_child_schemas(acc); - } - } -} \ No newline at end of file +// impl ChildSchemas for OpenAPI { +// fn add_child_schemas<'a>(&'a self, acc: &mut HashMap) { +// for (_path, _method, op, _item) in self.operations() { +// op.add_child_schemas(acc); +// } +// for (name, schema) in &self.schemas { +// let Some(schema) = schema.as_item() else { +// continue; +// }; +// acc.entry(name.clone()).or_insert(schema); +// schema.add_child_schemas(acc); +// } +// } +// } diff --git a/core/src/extractor.rs b/core/src/extractor.rs index 2534247..15e154f 100644 --- a/core/src/extractor.rs +++ b/core/src/extractor.rs @@ -2,287 +2,128 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use anyhow::{anyhow, Result}; use convert_case::{Case, Casing}; -use openapiv3::{APIKeyLocation, OpenAPI, ReferenceOr, Schema, SecurityScheme}; use openapiv3 as oa; +use openapiv3::{APIKeyLocation, OpenAPI, ReferenceOr, RefOr, Schema, SecurityScheme}; use tracing_ez::{debug, span, warn}; -use hir::{AuthLocation, AuthParam, AuthStrategy, HirSpec, Language, Location, Operation, Parameter, Record}; -use hir::{Oauth2Auth, TokenAuth}; +use hir::{ + AuthLocation, AuthParam, AuthStrategy, HirSpec, Language, Location, Oauth2Auth, Operation, + Parameter, Record, TokenAuth, +}; use mir::{Doc, DocFormat, NewType}; use mir::Ty; pub use record::*; -pub use resolution::{schema_ref_to_ty, schema_ref_to_ty_already_resolved, schema_to_ty}; -pub use resolution::*; +pub use ty::*; +pub use ty::{schema_ref_to_ty, schema_ref_to_ty2, schema_to_ty}; -mod resolution; +use crate::extractor::operation::extract_operation; +use crate::sanitize::sanitize; +use crate::util::{is_plural, singular}; + +mod operation; mod record; +mod ty; /// You might need to call add_operation_models after this -pub fn extract_spec(spec: &OpenAPI) -> Result { - let mut result = HirSpec::default(); - extract_api_operations(spec, &mut result)?; - extract_records(spec, &mut result)?; - let servers = extract_servers(spec)?; - let security = extract_security_strategies(spec); - - let api_docs_url = extract_api_docs_link(spec); - - result.servers = servers; - result.security = security; - result.api_docs_url = api_docs_url; - sanitize_spec(&mut result); - Ok(result) -} - -pub fn is_optional(name: &str, param: &Schema, parent: &Schema) -> bool { - if param.nullable { - return true; - } - let Some(req) = parent.get_required() else { - return false; - }; - !req.iter().any(|s| s == name) -} - -pub fn extract_request_schema<'a>( - operation: &'a oa::Operation, - spec: &'a OpenAPI, -) -> Result<&'a Schema> { - let body = operation - .request_body - .as_ref() - .ok_or_else(|| anyhow!("No request body for operation {:?}", operation))?; - let body = body.resolve(spec)?; - let content = body - .content - .get("application/json") - .ok_or_else(|| anyhow!("No json body"))?; - Ok(content.schema.as_ref().expect(&format!("Expecting a ref for {}", operation.operation_id.as_ref().map(|s| s.as_str()).unwrap_or_default())).resolve(spec)) -} - -pub fn extract_param(param: &ReferenceOr, spec: &OpenAPI) -> Result { - span!("extract_param", param = ?param); - - let param = param.resolve(spec)?; - let data = ¶m.data; - let param_schema_ref = data - .schema() - .ok_or_else(|| anyhow!("No schema for parameter: {:?}", param))?; - let ty = schema_ref_to_ty(param_schema_ref, spec); - let schema = param_schema_ref.resolve(spec); - Ok(Parameter { - doc: None, - name: data.name.to_string(), - optional: !data.required, - location: param.into(), - ty, - example: schema.example.clone(), - }) -} - -pub fn extract_inputs<'a>( - operation: &'a oa::Operation, - item: &'a oa::PathItem, - spec: &'a OpenAPI, -) -> Result> { - let mut inputs = operation - .parameters - .iter() - .map(|param| extract_param(param, spec)) - .collect::, _>>()?; - - let args = item.parameters.iter().map(|param| extract_param(param, spec)).collect::, _>>()?; - for param in args { - if !inputs.iter().any(|p| p.name == param.name) { - inputs.push(param); - } +pub fn extract_without_treeshake(spec: &OpenAPI) -> Result { + let mut hir = HirSpec::default(); + + // its important for built in schemas to come before operations, because + // we do some "create new schema" operations, and if those new ones overwrite + // the built in ones, that leads to confusion. + for (name, schema) in &spec.components.schemas { + let schema = schema.as_item().expect("Expected schema, not reference"); + extract_schema(&name, schema, spec, &mut hir); } - let schema = match extract_request_schema(operation, spec) { - Err(_) => return Ok(inputs), - Ok(schema) => schema, - }; - - if let oa::SchemaKind::Type(oa::Type::Array(oa::ArrayType { items, .. })) = &schema.kind { - let ty = if let Some(items) = items { - schema_ref_to_ty(&items, spec) - } else { - Ty::Any - }; - let ty = Ty::Array(Box::new(ty)); - inputs.push(Parameter { - name: "body".to_string(), - ty, - optional: false, - doc: None, - location: Location::Body, - example: schema.example.clone(), - }); - return Ok(inputs); - } - let mut props = schema.properties_iter(spec).peekable(); - if props.peek().is_some() { - let body_args = props.map(|(name, param)| { - let ty = schema_ref_to_ty(param, spec); - let param: &Schema = param.resolve(spec); - let optional = is_optional(name, param, schema); - let name = name.to_string(); - Parameter { - name, - ty, - optional, - doc: None, - location: Location::Body, - example: schema.example.clone(), - } - }); - for param in body_args { - if !inputs.iter().any(|p| p.name == param.name) { - inputs.push(param); - } - } - } else { - inputs.push(Parameter { - name: "body".to_string(), - ty: Ty::Any, - optional: false, - doc: None, - location: Location::Body, - example: schema.example.clone(), - }); + for (path, method, operation, item) in spec.operations() { + extract_operation(spec, path, method, operation, item, &mut hir); } - Ok(inputs) -} -pub fn extract_response_success<'a>( - operation: &'a oa::Operation, - spec: &'a OpenAPI, -) -> Option<&'a ReferenceOr> { - use openapiv3::StatusCode; + let servers = extract_servers(spec)?; + let security = extract_security_strategies(spec); - let response = operation - .responses - .responses - .get(&StatusCode::Code(200)) - .or_else(|| operation.responses.responses.get(&StatusCode::Code(201))) - .or_else(|| operation.responses.responses.get(&StatusCode::Code(202))) - .or_else(|| operation.responses.responses.get(&StatusCode::Code(204))) - .or_else(|| operation.responses.responses.get(&StatusCode::Code(302))); - response?; - let response = response - .unwrap_or_else(|| panic!("No success response for operation {:?}", operation)) - .resolve(spec) - .unwrap(); - response - .content - .get("application/json") - .and_then(|media| media.schema.as_ref()) -} + let api_docs_url = extract_api_docs_link(spec); -pub fn extract_operation_doc(operation: &oa::Operation, format: DocFormat) -> Option { - let mut doc_pieces = vec![]; - if let Some(summary) = operation.summary.as_ref() { - if !summary.is_empty() { - doc_pieces.push(summary.clone()); - } - } - if let Some(description) = operation.description.as_ref() { - if !description.is_empty() { - if !doc_pieces.is_empty() && description == &doc_pieces[0] {} else { - doc_pieces.push(description.clone()); - } - } - } - if let Some(external_docs) = operation.external_docs.as_ref() { - doc_pieces.push(match format { - DocFormat::Markdown => format!("See endpoint docs at <{}>.", external_docs.url), - DocFormat::Rst => format!( - "See endpoint docs at `{url} <{url}>`_.", - url = external_docs.url - ), - }) - } - if doc_pieces.is_empty() { - None - } else { - Some(Doc(doc_pieces.join("\n\n"))) - } + hir.servers = servers; + hir.security = security; + hir.api_docs_url = api_docs_url; + Ok(hir) } -pub fn extract_schema_docs(schema: &Schema) -> Option { - schema - .description - .as_ref() - .map(|d| Doc(d.trim().to_string())) +pub fn extract_spec(spec: &OpenAPI) -> Result { + let mut hir = extract_without_treeshake(spec)?; + treeshake(&mut hir); + validate(&hir); + debug!( + "Extracted {} schemas: {:?}", + hir.schemas.len(), + hir.schemas.keys() + ); + Ok(hir) } -pub fn make_name_from_method_and_url(method: &str, url: &str) -> String { - let names = url - .split('/') - .filter(|s| !s.starts_with('{')) - .collect::>(); - let last_group = url - .split('/') - .filter(|s| s.starts_with('{')) - .last() - .map(|s| { - let mut param = &s[1..s.len() - 1]; - if let Some(name) = names.last() { - if param.starts_with(name) { - param = ¶m[name.len() + 1..]; +pub fn validate(spec: &HirSpec) { + for (name, schema) in &spec.schemas { + if let Record::Struct(s) = schema { + for (field, schema) in s.fields.iter() { + if let Ty::Any(Some(inner)) = &schema.ty { + warn!( + "Field {} in schema {} is an Any with inner: {:?}", + field, name, inner + ); + } else if let Ty::Model(s) = &schema.ty { + if !spec.schemas.contains_key(s) { + warn!( + "Field {} in schema {} is a model that doesn't exist: {}", + field, name, s + ); + } } } - format!("_by_{}", param) - }) - .unwrap_or_default(); - let name = names.join("_"); - format!("{method}{name}{last_group}") + } + } } -pub fn extract_api_operations(spec: &OpenAPI, result: &mut HirSpec) -> Result<()> { - for (path, method, operation, item) in spec.operations() { - let name = match &operation.operation_id { - Some(name) => name - .replace(".", "_"), - None => make_name_from_method_and_url(method, path), - }; - let doc = extract_operation_doc(operation, DocFormat::Markdown); - let mut parameters = extract_inputs(operation, item, spec)?; - parameters.sort_by(|a, b| a.name.cmp(&b.name)); - let response_success = extract_response_success(operation, spec); - let mut needs_response_model = None; - let ret = match response_success { - None => Ty::Unit, - Some(ReferenceOr::Item(s)) => { - if matches!(s.kind, oa::SchemaKind::Type(oa::Type::Object(_))) { - needs_response_model = Some(s); - Ty::model(&format!("{}Response", name)) - } else { - schema_to_ty(s, spec) - } - } - Some(x @ ReferenceOr::Reference { .. }) => { - schema_ref_to_ty(x, spec) - } - }; +// pub fn deanonymize_array_items(spec: &mut HirSpec, openapi: &OpenAPI) { +// let current_schemas = spec +// .schemas +// .iter() +// .map(|(name, _)| name.clone()) +// .collect::>(); +// let mut new_schemas = vec![]; +// for (name, schema) in spec.schemas.iter_mut() { +// let Record::Struct(s) = schema else { +// continue; +// }; +// for (field, schema) in s.fields.iter_mut() { +// let Ty::Array(item) = &mut schema.ty else { +// continue; +// }; +// let Ty::Any(Some(inner)) = item.as_mut() else { +// continue; +// }; +// let Some(name) = create_unique_name(¤t_schemas, name, field) else { +// continue; +// }; +// let record = create_record(&name, inner, openapi); +// *item = Box::new(Ty::model(&name)); +// new_schemas.push((name, record)); +// } +// } +// spec.schemas.extend(new_schemas); +// } - if let Some(s) = needs_response_model { - let response_name = format!("{}Response", name); - result.schemas.insert(response_name.clone(), create_record(&response_name, s, spec)); - } - result.operations.push(Operation { - name, - doc, - parameters, - ret, - path: path.to_string(), - method: method.to_string(), - }); +pub fn is_optional(name: &str, param: &Schema, parent: &Schema) -> bool { + if param.nullable { + return true; } - Ok(()) + let Some(req) = parent.get_required() else { + return false; + }; + !req.iter().any(|s| s == name) } - fn extract_servers(spec: &OpenAPI) -> Result> { let mut servers = BTreeMap::new(); if spec.servers.len() == 1 { @@ -291,12 +132,7 @@ fn extract_servers(spec: &OpenAPI) -> Result> { return Ok(servers); } 'outer: for server in &spec.servers { - for keyword in [ - "beta", - "production", - "development", - "sandbox", - ] { + for keyword in ["beta", "production", "development", "sandbox"] { if matches!(&server.description, Some(desc) if desc.to_lowercase().contains(keyword)) { servers.insert(keyword.to_string(), server.url.clone()); continue 'outer; @@ -346,19 +182,28 @@ fn remove_unused(spec: &mut HirSpec) { } } -fn sanitize_spec(spec: &mut HirSpec) { +/// effectively performs tree shaking on the spec, strip out models that are unused +fn treeshake(spec: &mut HirSpec) { // skip alias structs - let optional_short_circuit: HashMap = spec.schemas.iter() + let optional_short_circuit: HashMap = spec + .schemas + .iter() .filter(|(_, r)| r.optional()) .filter_map(|(_, r)| { - let Record::TypeAlias(alias, field) = r else { return None; }; - let Ty::Model(resolved) = &field.ty else { return None; }; + let Record::TypeAlias(alias, field) = r else { + return None; + }; + let Ty::Model(resolved) = &field.ty else { + return None; + }; Some((alias.clone(), resolved.clone())) }) .collect(); for record in spec.schemas.values_mut() { for field in record.fields_mut() { - let Ty::Model(name) = &field.ty else { continue; }; + let Ty::Model(name) = &field.ty else { + continue; + }; let Some(rename_to) = optional_short_circuit.get(name) else { continue; }; @@ -375,7 +220,6 @@ fn sanitize_spec(spec: &mut HirSpec) { remove_unused(spec); } - pub fn spec_defines_auth(spec: &HirSpec) -> bool { !spec.security.is_empty() } @@ -386,11 +230,17 @@ fn extract_key_location(loc: &APIKeyLocation, name: &str) -> AuthLocation { if ["bearer_auth", "bearer"].contains(&&*name.to_case(Case::Snake)) { AuthLocation::Bearer } else { - AuthLocation::Header { key: name.to_string() } + AuthLocation::Header { + key: name.to_string(), + } } } - APIKeyLocation::Query => AuthLocation::Query { key: name.to_string() }, - APIKeyLocation::Cookie => AuthLocation::Cookie { key: name.to_string() }, + APIKeyLocation::Query => AuthLocation::Query { + key: name.to_string(), + }, + APIKeyLocation::Cookie => AuthLocation::Cookie { + key: name.to_string(), + }, } } @@ -403,9 +253,13 @@ pub fn extract_security_strategies(spec: &OpenAPI) -> Vec { continue; } let (scheme_name, _scopes) = requirement.iter().next().unwrap(); - let scheme = schemes.get(scheme_name).expect(&format!("Security scheme {} not found.", scheme_name)); + let scheme = schemes + .get(scheme_name) + .expect(&format!("Security scheme {} not found.", scheme_name)); debug!("Found security scheme for {}: {:?}", scheme_name, scheme); - let scheme = scheme.as_item().expect("TODO support refs in securitySchemes"); + let scheme = scheme + .as_item() + .expect("TODO support refs in securitySchemes"); match scheme { SecurityScheme::APIKey { location, name, .. } => { let location = extract_key_location(&location, &name); @@ -422,12 +276,23 @@ pub fn extract_security_strategies(spec: &OpenAPI) -> Vec { strats.push(AuthStrategy::OAuth2(Oauth2Auth { auth_url: flow.authorization_url.clone(), exchange_url: flow.token_url.clone(), - refresh_url: flow.refresh_url.clone().unwrap_or_else(|| flow.token_url.clone()), - scopes: flow.scopes.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + refresh_url: flow + .refresh_url + .clone() + .unwrap_or_else(|| flow.token_url.clone()), + scopes: flow + .scopes + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), })) } } - SecurityScheme::HTTP { scheme, bearer_format, description } => { + SecurityScheme::HTTP { + scheme, + bearer_format, + description, + } => { strats.push(AuthStrategy::Token(TokenAuth { name: scheme_name.to_string(), fields: vec![AuthParam { @@ -458,42 +323,22 @@ pub fn extract_newtype(name: &str, schema: &oa::Schema, spec: &OpenAPI) -> NewTy fn get_name(schema_ref: oa::SchemaReference) -> String { match schema_ref { oa::SchemaReference::Schema { schema } => schema, - oa::SchemaReference::Property { property, .. } => property + oa::SchemaReference::Property { property, .. } => property, } } - /// Add the models for operations that have structs for their required params. /// E.g. linkTokenCreate has >3 required params, so it has a struct. pub fn add_operation_models(lang: Language, mut spec: HirSpec) -> Result { let mut new_schemas = vec![]; for op in &spec.operations { if op.use_required_struct(lang) { - new_schemas.push((op.required_struct_name(), Record::Struct(op.required_struct(lang)))); + new_schemas.push(( + op.required_struct_name(), + Record::Struct(op.required_struct(lang)), + )); } } spec.schemas.extend(new_schemas); Ok(spec) } - - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_make_operation_name() { - let method = "get"; - let url = "/diffs/{id}"; - let op_name = make_name_from_method_and_url(method, url); - assert_eq!(op_name, "get_diffs_by_id"); - } - - #[test] - fn test_make_operation_name2() { - let method = "get"; - let url = "/user/{user_id}/account/{account_id}"; - let op_name = make_name_from_method_and_url(method, url); - assert_eq!(op_name, "get_user_account_by_id"); - } -} diff --git a/core/src/extractor/operation.rs b/core/src/extractor/operation.rs new file mode 100644 index 0000000..3dcd956 --- /dev/null +++ b/core/src/extractor/operation.rs @@ -0,0 +1,265 @@ +use anyhow::{anyhow, Result}; +use convert_case::{Case, Casing}; +use openapiv3::{ + ArrayType, OpenAPI, Operation, Parameter, PathItem, ReferenceOr, RefOr, Schema, SchemaKind, + Type, +}; +use tracing_ez::span; + +use hir::{HirSpec, Location}; +use mir::{Doc, DocFormat, Ty}; + +use crate::extractor; +use crate::extractor::{is_primitive, schema_ref_to_ty, schema_ref_to_ty2, schema_to_ty}; +use crate::extractor::record::extract_schema; + +// make a name for hir::Operation +fn make_name(operation_id: Option<&String>, method: &str, path: &str) -> String { + if let Some(name) = operation_id { + return name.replace(".", "_"); + } + let names = path + .split('/') + .filter(|s| !s.starts_with('{')) + .collect::>(); + let last_group = path + .split('/') + .filter(|s| s.starts_with('{')) + .last() + .map(|s| { + let mut param = &s[1..s.len() - 1]; + if let Some(name) = names.last() { + if param.starts_with(name) { + param = ¶m[name.len() + 1..]; + } + } + format!("_by_{}", param) + }) + .unwrap_or_default(); + let name = names.join("_"); + format!("{method}{name}{last_group}") +} + +pub fn extract_operation( + spec: &OpenAPI, + path: &str, + method: &str, + op: &Operation, + item: &PathItem, + hir: &mut HirSpec, +) { + let name = make_name(op.operation_id.as_ref(), method, path); + let doc = extract_doc(op, DocFormat::Markdown); + let mut parameters = extract_parameters(op, item, spec).unwrap(); + parameters.sort_by(|a, b| a.name.cmp(&b.name)); + let ret = match get_res(op, spec) { + None => Ty::Unit, + Some(x @ ReferenceOr::Reference { .. }) => schema_ref_to_ty(x, spec), + Some(ReferenceOr::Item(res)) => { + let name = format!("{}Response", name.to_case(Case::Pascal)); + extract_schema(&name, res, spec, hir); + if is_primitive(res, spec) { + schema_to_ty(res, spec) + } else { + Ty::Model(name) + } + } + }; + hir.operations.push(hir::Operation { + name, + doc, + parameters, + ret, + path: path.to_string(), + method: method.to_string(), + }); +} + +fn extract_doc(operation: &Operation, format: DocFormat) -> Option { + let mut doc_pieces = vec![]; + if let Some(summary) = operation.summary.as_ref() { + if !summary.is_empty() { + doc_pieces.push(summary.clone()); + } + } + if let Some(description) = operation.description.as_ref() { + if !description.is_empty() { + if !doc_pieces.is_empty() && description == &doc_pieces[0] { + } else { + doc_pieces.push(description.clone()); + } + } + } + if let Some(external_docs) = operation.external_docs.as_ref() { + doc_pieces.push(match format { + DocFormat::Markdown => format!("See endpoint docs at <{}>.", external_docs.url), + DocFormat::Rst => format!( + "See endpoint docs at `{url} <{url}>`_.", + url = external_docs.url + ), + }) + } + if doc_pieces.is_empty() { + None + } else { + Some(Doc(doc_pieces.join("\n\n"))) + } +} + +pub fn extract_parameters( + op: &Operation, + item: &PathItem, + spec: &OpenAPI, +) -> Result> { + let mut inputs = op + .parameters + .iter() + .map(|param| extract_param(param, spec)) + .collect::, _>>()?; + + let args = item + .parameters + .iter() + .map(|param| extract_param(param, spec)) + .collect::, _>>()?; + for param in args { + if !inputs.iter().any(|p| p.name == param.name) { + inputs.push(param); + } + } + + let Some(body) = get_body(op, spec) else { + return Ok(inputs); + }; + + if let SchemaKind::Type(Type::Array(ArrayType { items, .. })) = &body.kind { + let ty = if let Some(items) = items { + schema_ref_to_ty(&items, spec) + } else { + Ty::default() + }; + let ty = Ty::Array(Box::new(ty)); + inputs.push(hir::Parameter { + name: "body".to_string(), + ty, + optional: false, + doc: None, + location: Location::Body, + example: body.example.clone(), + }); + return Ok(inputs); + } + let mut props = body.properties_iter(spec).peekable(); + + if props.peek().is_some() { + let body_args = props.map(|(name, param)| { + let ty = schema_ref_to_ty(param, spec); + let param: &Schema = param.resolve(spec); + let optional = extractor::is_optional(name, param, body); + let name = name.to_string(); + hir::Parameter { + name, + ty, + optional, + doc: None, + location: Location::Body, + example: body.example.clone(), + } + }); + for param in body_args { + if !inputs.iter().any(|p| p.name == param.name) { + inputs.push(param); + } + } + } else { + inputs.push(hir::Parameter { + name: "body".to_string(), + ty: Ty::default(), + optional: false, + doc: None, + location: Location::Body, + example: body.example.clone(), + }); + } + Ok(inputs) +} + +pub fn get_body<'a>(op: &'a Operation, spec: &'a OpenAPI) -> Option<&'a Schema> { + let body = op.request_body.as_ref()?; + let body = body.resolve(spec).unwrap(); + let content = body.content.get("application/json")?; + let body = content.schema.as_ref()?; + Some(body.resolve(spec)) +} + +pub fn get_res<'a>(operation: &'a Operation, spec: &'a OpenAPI) -> Option<&'a RefOr> { + use openapiv3::StatusCode; + + let res = &operation.responses.responses; + let Some(res) = res + .get(&StatusCode::Code(200)) + .or_else(|| res.get(&StatusCode::Code(201))) + .or_else(|| res.get(&StatusCode::Code(202))) + .or_else(|| res.get(&StatusCode::Code(204))) + .or_else(|| res.get(&StatusCode::Code(302))) + else { + panic!("No success response for operation {:?}", operation); + }; + let res = res.resolve(spec).unwrap(); + res.content + .get("application/json") + .and_then(|media| media.schema.as_ref()) +} + +pub fn extract_param(param: &ReferenceOr, spec: &OpenAPI) -> Result { + span!("extract_param", param = ?param); + let param = param.resolve(spec)?; + let data = ¶m.data; + let param_schema_ref = data + .schema() + .ok_or_else(|| anyhow!("No schema for parameter: {:?}", param))?; + let schema = param_schema_ref.resolve(spec); + let ty = schema_ref_to_ty2(param_schema_ref, spec, schema); + Ok(hir::Parameter { + doc: None, + name: data.name.to_string(), + optional: !data.required, + location: param.into(), + ty, + example: schema.example.clone(), + }) +} + +#[cfg(test)] +mod tests { + use serde_yaml::from_str; + + use super::*; + + #[test] + fn test_make_operation_name() { + let method = "get"; + let url = "/diffs/{id}"; + let op_name = make_name(None, method, url); + assert_eq!(op_name, "get_diffs_by_id"); + } + + #[test] + fn test_make_operation_name2() { + let method = "get"; + let url = "/user/{user_id}/account/{account_id}"; + let op_name = make_name(None, method, url); + assert_eq!(op_name, "get_user_account_by_id"); + } + + #[test] + pub fn test_required_args() { + let spec = include_str!("../../../test_specs/basic.yaml"); + let mut spec: OpenAPI = from_str(spec).unwrap(); + spec.paths.retain(|k, _| k == "/link/token/create"); + let (operation, path) = spec.get_operation("linkTokenCreate").unwrap(); + let inputs = extract_parameters(&operation, path, &spec).unwrap(); + assert_eq!(inputs[8].name, "user_token"); + assert_eq!(inputs[8].optional, true); + } +} diff --git a/core/src/extractor/record.rs b/core/src/extractor/record.rs index 8b9174b..71fe86a 100644 --- a/core/src/extractor/record.rs +++ b/core/src/extractor/record.rs @@ -1,46 +1,76 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashSet}; -use anyhow::Result; -use indexmap::IndexMap; +use convert_case::{Case, Casing}; /// Records are the "model"s of the MIR world. model is a crazy overloaded word though. +use openapiv3::{ + ObjectType, OpenAPI, ReferenceOr, RefOr, RefOrMap, Schema, SchemaData, SchemaKind, + SchemaReference, StringType, Type, +}; -use openapiv3::{ObjectType, OpenAPI, ReferenceOr, Schema, SchemaData, SchemaKind, SchemaReference, StringType, Type, RefOrMap}; -use tracing::warn; - -use hir::{HirField, Record, StrEnum, Struct, NewType, HirSpec}; -use mir::Doc; +use hir::{HirField, HirSpec, NewType, Record, StrEnum, Struct}; +use mir::{Doc, Ty}; use crate::extractor; -use crate::child_schemas::ChildSchemas; -use crate::extractor::{schema_ref_to_ty_already_resolved, schema_to_ty}; +use crate::extractor::{ + is_optional, is_primitive, schema_ref_to_ty, schema_ref_to_ty2, schema_to_ty, +}; +use crate::sanitize::sanitize; +use crate::util::{is_plural, singular}; -fn properties_to_fields(properties: &RefOrMap, schema: &Schema, spec: &OpenAPI) -> BTreeMap { +fn extract_fields( + properties: &RefOrMap, + parent: &Schema, + spec: &OpenAPI, + hir: &mut HirSpec, +) -> BTreeMap { properties .iter() - .map(|(name, field_schema_ref)| { - let field_schema = field_schema_ref.resolve(spec); - let ty = schema_ref_to_ty_already_resolved( - field_schema_ref, - spec, - field_schema, - ); - let optional = extractor::is_optional(name, field_schema, schema); - (name.clone(), HirField { - ty, - optional, - doc: extractor::extract_schema_docs(field_schema), - example: None, - flatten: false, - }) + .map(|(name, schema_ref)| { + let schema = schema_ref.resolve(spec); + let mut ty = None; + if let RefOr::Item(schema) = schema_ref { + if !is_primitive(schema, spec) { + let name = sanitize(name).to_case(Case::Pascal); + ty = extract_schema(&name, schema, spec, hir); + } + } + let ty = ty.unwrap_or_else(|| schema_ref_to_ty2(schema_ref, spec, schema)); + let optional = is_optional(name, schema, parent); + ( + name.clone(), + HirField { + ty, + optional, + doc: extract_docs(schema), + example: schema.example.clone(), + flatten: false, + }, + ) }) .collect() } +fn create_field(field_schema_ref: &ReferenceOr, spec: &OpenAPI) -> HirField { + let field_schema = field_schema_ref.resolve(spec); + let ty = schema_ref_to_ty2(field_schema_ref, spec, field_schema); + let optional = field_schema.nullable; + let example = field_schema.example.clone(); + let doc = extract_docs(field_schema); + HirField { + ty, + optional, + doc, + example, + flatten: false, + } +} + pub fn effective_length(all_of: &[ReferenceOr]) -> usize { let mut length = 0; for schema_ref in all_of { length += schema_ref.as_ref_str().map(|_s| 1).unwrap_or_default(); - length += schema_ref.as_item() + length += schema_ref + .as_item() .map(|s| s.properties()) .map(|s| s.iter().len()) .unwrap_or_default(); @@ -48,75 +78,98 @@ pub fn effective_length(all_of: &[ReferenceOr]) -> usize { length } -pub fn create_record(name: &str, schema: &Schema, spec: &OpenAPI) -> Record { +pub fn extract_schema( + name: &str, + schema: &Schema, + spec: &OpenAPI, + hir: &mut HirSpec, +) -> Option { + println!("Extracting schema: {}", name); let name = name.to_string(); - match &schema.kind { - // The base case, a regular object - SchemaKind::Type(Type::Object(ObjectType { properties, .. })) => { - let fields = properties_to_fields(properties, schema, spec); - Record::Struct(Struct { - name, - fields, - nullable: schema.nullable, - docs: schema.description.as_ref().map(|d| Doc(d.trim().to_string())), - }) - } - // An enum - SchemaKind::Type(Type::String(StringType { enumeration, .. })) - if !enumeration.is_empty() => - { - Record::Enum(StrEnum { - name, - variants: enumeration - .iter() - .map(|s| s.to_string()) - .collect(), - docs: schema.description.as_ref().map(|d| Doc(d.clone())), - }) - } - // A newtype with multiple fields - SchemaKind::AllOf { all_of } => { - let all_of = all_of.as_slice(); - if effective_length(all_of) == 1 { - Record::TypeAlias(name, HirField { - ty: schema_ref_to_ty_already_resolved(&all_of[0], spec, schema), - optional: schema.nullable, - ..HirField::default() - }) - } else { - create_record_from_all_of(&name, all_of, &schema.data, spec) - } + + let k = &schema.kind; + if let SchemaKind::Type(Type::Object(ObjectType { properties, .. })) = k { + let fields = extract_fields(properties, schema, spec, hir); + let s = Struct { + name: name.clone(), + fields, + nullable: schema.nullable, + docs: schema + .description + .as_ref() + .map(|d| Doc(d.trim().to_string())), + }; + hir.insert_schema(s); + return None; + } + if let SchemaKind::Type(Type::String(StringType { enumeration, .. })) = k { + if !enumeration.is_empty() { + let s = StrEnum { + name: name.clone(), + variants: enumeration.iter().map(|s| s.clone()).collect(), + docs: schema.description.as_ref().map(|d| Doc(d.clone())), + }; + hir.insert_schema(s); + return None; } - // Default case, a newtype with a single field - _ => Record::NewType(NewType { - name, - fields: vec![HirField { - ty: schema_to_ty(schema, spec), - optional: schema.nullable, - doc: None, - example: None, - flatten: false, - }], - docs: schema.description.as_ref().map(|d| Doc(d.clone())), - }), } + if let SchemaKind::AllOf { all_of } = k { + extract_all_of(name, all_of.as_slice(), &schema.data, spec, hir); + return None; + } + 'foo: { + let SchemaKind::Type(Type::Array(arr)) = k else { + break 'foo; + }; + let Some(items) = &arr.items.as_ref() else { + break 'foo; + }; + let Some(item) = items.as_item() else { + break 'foo; + }; + let schema_names = hir.schemas.iter().map(|(k, _)| k.clone()).collect(); + let Some(name) = create_unique_name(&schema_names, &name, &name) else { + break 'foo; + }; + extract_schema(&name, item, spec, hir); + return Some(Ty::Array(Box::new(Ty::model(&name)))); + } + extract_newtype(name, schema, spec, hir); + None } - -fn create_field(field_schema_ref: &ReferenceOr, spec: &OpenAPI) -> HirField { - let field_schema = field_schema_ref.resolve(spec); - let ty = schema_ref_to_ty_already_resolved( - field_schema_ref, - spec, - field_schema, - ); - let optional = field_schema.nullable; - let example = field_schema.example.clone(); - let doc = field_schema.description.clone().map(Doc); - HirField { ty, optional, doc, example, flatten: false } +fn extract_newtype(name: String, schema: &Schema, spec: &OpenAPI, hir: &mut HirSpec) { + let t = NewType { + name: name.clone(), + fields: vec![HirField { + ty: schema_to_ty(schema, spec), + optional: schema.nullable, + doc: None, + example: None, + flatten: false, + }], + docs: schema.description.as_ref().map(|d| Doc(d.clone())), + }; + hir.insert_schema(t); } -fn create_record_from_all_of(name: &str, all_of: &[ReferenceOr], schema_data: &SchemaData, spec: &OpenAPI) -> Record { +fn extract_all_of( + name: String, + all_of: &[ReferenceOr], + data: &SchemaData, + spec: &OpenAPI, + hir: &mut HirSpec, +) { + if effective_length(&all_of) == 1 { + let ty = schema_ref_to_ty(&all_of[0], spec); + let field = HirField { + ty, + optional: data.nullable, + ..HirField::default() + }; + hir.insert_schema(Record::TypeAlias(name.clone(), field)); + return; + } let mut fields = BTreeMap::new(); for schema in all_of { match &schema { @@ -139,48 +192,75 @@ fn create_record_from_all_of(name: &str, all_of: &[ReferenceOr], schema_ } } } - Record::Struct(Struct { - nullable: schema_data.nullable, + let s = Struct { + nullable: data.nullable, name: name.to_string(), fields, - docs: schema_data.description.as_ref().map(|d| Doc(d.clone())), - }) + docs: data.description.as_ref().map(|d| Doc(d.clone())), + }; + hir.insert_schema(s); } -// records are data types: structs, newtypes -pub fn extract_records(spec: &OpenAPI, result: &mut HirSpec) -> Result<()> { - let mut schema_lookup = HashMap::new(); - - spec.add_child_schemas(&mut schema_lookup); - for (mut name, schema) in schema_lookup { - let rec = create_record(&name, schema, spec); - let name = rec.name().to_string(); - result.schemas.insert(name, rec); +/// When encountering anonymous nested structs (e.g. array items), use this function to come up with a name. +/// name: the object it resides on +/// field: the field name +fn create_unique_name( + current_schemas: &HashSet, + name: &str, + field: &str, +) -> Option { + if is_plural(field) { + let singular_field = singular(field).to_case(Case::Pascal); + if !current_schemas.contains(&singular_field) { + return Some(singular_field); + } + let singular_field = format!("{}{}", name.to_case(Case::Pascal), singular_field); + if !current_schemas.contains(&singular_field) { + return Some(singular_field); + } } - - for (name, schema_ref) in &spec.schemas { - let Some(reference) = schema_ref.as_ref_str() else { continue; }; - result.schemas.insert(name.clone(), Record::TypeAlias(name.clone(), create_field(&schema_ref, spec))); + let singular_field = format!("{}Item", field.to_case(Case::Pascal)); + if !current_schemas.contains(&singular_field) { + return Some(singular_field); + } + let singular_field = format!("{}{}", name.to_case(Case::Pascal), singular_field); + if !current_schemas.contains(&singular_field) { + return Some(singular_field); } - Ok(()) + None } #[cfg(test)] mod tests { use openapiv3::{OpenAPI, Schema, SchemaData, SchemaKind}; + use serde_yaml::from_str; - use crate::extractor::record::create_record_from_all_of; + use hir::HirSpec; + + use super::*; #[test] fn test_all_of_required_set_correctly() { - let mut additional_props: Schema = serde_yaml::from_str(include_str!("./pet_tag.yaml")).unwrap(); - let SchemaKind::AllOf { all_of } = &additional_props.kind else { panic!() }; + let mut hir = HirSpec::default(); + let mut schema: Schema = from_str(include_str!("./pet_tag.yaml")).unwrap(); + let SchemaKind::AllOf { all_of } = &schema.kind else { + panic!() + }; let spec = OpenAPI::default(); - let rec = create_record_from_all_of("PetTag", &all_of, &SchemaData::default(), &spec); + let name = "PetTag".to_string(); + extract_all_of(name, &all_of, &SchemaData::default(), &spec, &mut hir); + let rec = hir.schemas.get("PetTag").unwrap(); let mut fields = rec.fields(); let eye_color = fields.next().unwrap(); let weight = fields.next().unwrap(); assert_eq!(eye_color.optional, false); assert_eq!(weight.optional, true); } -} \ No newline at end of file +} + +pub fn extract_docs(schema: &Schema) -> Option { + schema + .description + .as_ref() + .map(|d| Doc(d.trim().to_string())) +} diff --git a/core/src/extractor/resolution.rs b/core/src/extractor/resolution.rs deleted file mode 100644 index de8465e..0000000 --- a/core/src/extractor/resolution.rs +++ /dev/null @@ -1,113 +0,0 @@ -use openapiv3::{ArrayType, OpenAPI, ReferenceOr, Schema, SchemaKind, SchemaReference}; -use tracing::warn; - -use mir::Ty; - -use openapiv3 as oa; - -pub fn schema_ref_to_ty(schema_ref: &ReferenceOr, spec: &OpenAPI) -> Ty { - let schema = schema_ref.resolve(spec); - schema_ref_to_ty_already_resolved(schema_ref, spec, schema) -} - -pub fn schema_ref_to_ty_already_resolved(schema_ref: &ReferenceOr, spec: &OpenAPI, schema: &Schema) -> Ty { - if is_primitive(schema, spec) { - schema_to_ty(schema, spec) - } else { - match schema_ref { - ReferenceOr::Reference { reference } => { - let r = oa::SchemaReference::from_str(reference); - match r { - SchemaReference::Schema { schema: s } => Ty::model(&s), - SchemaReference::Property { schema: _, property: _ } => unimplemented!(), - } - } - ReferenceOr::Item(schema) => schema_to_ty(schema, spec) - } - } -} - -/// You probably want schema_ref_to_ty, not this method. Reason being, you want -/// to use the ref'd model if one exists (e.g. User instead of resolving to Ty::Any) -pub fn schema_to_ty(schema: &Schema, spec: &OpenAPI) -> Ty { - match &schema.kind { - SchemaKind::Type(oa::Type::String(s)) => { - match s.format.as_str() { - "decimal" => Ty::Currency { - serialization: mir::DecimalSerialization::String, - }, - "integer" => Ty::Integer { serialization: mir::IntegerSerialization::String }, - "date" => Ty::Date { - serialization: mir::DateSerialization::Iso8601, - }, - "date-time" => Ty::DateTime, - _ => Ty::String, - } - } - SchemaKind::Type(oa::Type::Number(_)) => Ty::Float, - SchemaKind::Type(oa::Type::Integer(_)) => { - let null_as_zero = schema.data.extensions.get("x-null-as-zero") - .and_then(|v| v.as_bool()).unwrap_or(false); - if null_as_zero { - return Ty::Integer { serialization: mir::IntegerSerialization::NullAsZero }; - } - match schema.data.extensions.get("x-format").and_then(|s| s.as_str()) { - Some("date") => Ty::Date { - serialization: mir::DateSerialization::Integer, - }, - _ => Ty::Integer { serialization: mir::IntegerSerialization::Simple }, - } - } - SchemaKind::Type(oa::Type::Boolean {}) => Ty::Boolean, - SchemaKind::Type(oa::Type::Object(_)) => { - if let Some(title) = &schema.title { - Ty::model(&title) - } else { - Ty::Any - } - }, - SchemaKind::Type(oa::Type::Array(ArrayType { - items: Some(item), .. - })) => { - let inner = schema_ref_to_ty(&item, spec); - Ty::Array(Box::new(inner)) - } - SchemaKind::Type(oa::Type::Array(ArrayType { items: None, .. })) => { - warn!("Array with no items. Defaulting to Array"); - Ty::Array(Box::new(Ty::Any)) - } - SchemaKind::Any(..) => Ty::Any, - SchemaKind::AllOf { all_of } => { - if all_of.len() == 1 { - schema_ref_to_ty(&all_of[0], spec) - } else { - Ty::Any - } - } - SchemaKind::OneOf { .. } => Ty::Any, - SchemaKind::AnyOf { .. } => Ty::Any, - SchemaKind::Not { .. } => Ty::Any, - } -} - - -pub fn is_primitive(schema: &Schema, spec: &OpenAPI) -> bool { - use openapiv3::SchemaKind::*; - use openapiv3::Type::*; - match &schema.kind { - Type(String(_)) => true, - Type(Number(_)) => true, - Type(Integer(_)) => true, - Type(Boolean {}) => true, - Type(Array(ArrayType { - items: Some(inner), .. - })) => { - let inner = inner.resolve(spec); - is_primitive(inner, spec) - } - SchemaKind::AllOf { all_of } => { - all_of.len() == 1 && is_primitive(all_of[0].resolve(spec), spec) - } - _ => false, - } -} diff --git a/core/src/extractor/ty.rs b/core/src/extractor/ty.rs new file mode 100644 index 0000000..29070f0 --- /dev/null +++ b/core/src/extractor/ty.rs @@ -0,0 +1,123 @@ +use openapiv3 as oa; +use openapiv3::{ArrayType, OpenAPI, ReferenceOr, Schema, SchemaKind, SchemaReference}; +use serde_json::Value; +use tracing::warn; + +use mir::Ty; + +use crate::sanitize::sanitize; + +pub fn schema_ref_to_ty(schema_ref: &ReferenceOr, spec: &OpenAPI) -> Ty { + let schema = schema_ref.resolve(spec); + schema_ref_to_ty2(schema_ref, spec, schema) +} + +pub fn schema_ref_to_ty2(schema_ref: &ReferenceOr, spec: &OpenAPI, schema: &Schema) -> Ty { + if is_primitive(schema, spec) { + schema_to_ty(schema, spec) + } else { + match schema_ref { + ReferenceOr::Reference { reference } => { + let r = SchemaReference::from_str(reference); + schema_ref_to_model(r) + } + ReferenceOr::Item(schema) => schema_to_ty(schema, spec), + } + } +} + +pub fn schema_ref_to_model(reference: SchemaReference) -> Ty { + match reference { + SchemaReference::Schema { schema } => Ty::model(&schema), + SchemaReference::Property { .. } => unimplemented!(), + } +} + +/// You probably want schema_ref_to_ty, not this method. Reason being, you want +/// to use the ref'd model if one exists (e.g. User instead of resolving to Ty::Any) +pub fn schema_to_ty(schema: &Schema, spec: &OpenAPI) -> Ty { + match &schema.kind { + SchemaKind::Type(oa::Type::String(s)) => match s.format.as_str() { + "decimal" => Ty::Currency { + serialization: mir::DecimalSerialization::String, + }, + "integer" => Ty::Integer { + serialization: mir::IntegerSerialization::String, + }, + "date" => Ty::Date { + serialization: mir::DateSerialization::Iso8601, + }, + "date-time" => Ty::DateTime, + _ => Ty::String, + }, + SchemaKind::Type(oa::Type::Number(_)) => Ty::Float, + SchemaKind::Type(oa::Type::Integer(_)) => { + let ext = &schema.data.extensions; + let null_as_zero = ext + .get("x-null-as-zero") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if null_as_zero { + return Ty::Integer { + serialization: mir::IntegerSerialization::NullAsZero, + }; + } + match schema + .data + .extensions + .get("x-format") + .and_then(|s| s.as_str()) + { + Some("date") => Ty::Date { + serialization: mir::DateSerialization::Integer, + }, + _ => Ty::Integer { + serialization: mir::IntegerSerialization::Simple, + }, + } + } + SchemaKind::Type(oa::Type::Boolean {}) => Ty::Boolean, + SchemaKind::Type(oa::Type::Object(_)) => Ty::Any(Some(schema.clone())), + SchemaKind::Type(oa::Type::Array(ArrayType { + items: Some(item), .. + })) => { + let inner = schema_ref_to_ty(&item, spec); + Ty::Array(Box::new(inner)) + } + SchemaKind::Type(oa::Type::Array(ArrayType { items: None, .. })) => { + warn!("Array with no items. Defaulting to Array"); + Ty::Array(Box::new(Ty::default())) + } + SchemaKind::Any(..) => Ty::default(), + SchemaKind::AllOf { all_of } => { + if all_of.len() == 1 { + schema_ref_to_ty(&all_of[0], spec) + } else { + Ty::default() + } + } + SchemaKind::OneOf { .. } => Ty::default(), + SchemaKind::AnyOf { .. } => Ty::default(), + SchemaKind::Not { .. } => Ty::default(), + } +} + +/// what exactly is this? +pub fn is_primitive(schema: &Schema, spec: &OpenAPI) -> bool { + use openapiv3::SchemaKind::*; + use openapiv3::Type::*; + match &schema.kind { + Type(String(s)) => s.enumeration.is_empty(), + Type(Number(_)) => true, + Type(Integer(_)) => true, + Type(Boolean {}) => true, + Type(Array(ArrayType { + items: Some(inner), .. + })) => { + let inner = inner.resolve(spec); + is_primitive(inner, spec) + } + AllOf { all_of } => all_of.len() == 1 && is_primitive(all_of[0].resolve(spec), spec), + _ => false, + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 3f67049..6270ce5 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -5,8 +5,10 @@ pub use fs::*; pub use options::*; pub use template::*; +pub mod child_schemas; +pub mod extractor; pub mod fs; mod options; -pub mod extractor; +pub mod sanitize; mod template; -pub mod child_schemas; \ No newline at end of file +pub mod util; diff --git a/core/src/sanitize.rs b/core/src/sanitize.rs new file mode 100644 index 0000000..c00800e --- /dev/null +++ b/core/src/sanitize.rs @@ -0,0 +1,36 @@ +use std::ops::Deref; +use std::sync::OnceLock; + +use regex_lite::Regex; + +fn is_restricted(s: &str) -> bool { + ["type", "use", "ref", "self", "match"].contains(&s) +} + +pub fn sanitize(s: &str) -> String { + let r = if s.contains('(') { + static NO_PAREN: OnceLock = OnceLock::new(); + let re = NO_PAREN.get_or_init(|| Regex::new(r"\([^)]*\)").unwrap()); + re.replace_all(s, "") + } else { + s.into() + }; + let s = r.trim().to_string(); + // if is_restricted(&s) { + // format!("{}_", s) + if s.chars().next().unwrap().is_numeric() { + format!("_{}", s) + } else { + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize() { + assert_eq!(sanitize("coupon type (foo)"), "coupon type"); + } +} diff --git a/core/src/util.rs b/core/src/util.rs new file mode 100644 index 0000000..e43bdc6 --- /dev/null +++ b/core/src/util.rs @@ -0,0 +1,58 @@ +use std::borrow::Cow; + +/// really dumb approximate attempt at making singular +pub fn singular(s: &str) -> Cow<'_, str> { + if s.ends_with("ies") { + let mut s = s[..s.len() - 3].to_string(); + s.push('y'); + Cow::Owned(s) + } else if s.ends_with("es") { + Cow::Owned(s[..s.len() - 2].to_string()) + } else if !s.ends_with("ss") && s.ends_with('s') { + Cow::Borrowed(&s[..s.len() - 1]) + } else { + Cow::Borrowed(s) + } +} + +pub fn is_plural(s: &str) -> bool { + if s.ends_with("ies") { + true + } else if s.ends_with("es") { + true + } else if !s.ends_with("ss") && s.ends_with('s') { + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_make_singular() { + assert_eq!(singular("cats"), "cat"); + assert_eq!(singular("class"), "class"); + assert_eq!(singular("parties"), "party"); + assert_eq!(singular("party"), "party"); + assert_eq!(singular("party"), "party"); + + // assert!(singular("mice") == "mouse"); + // assert!(singular("alumni") == "alumnus"); + } + + #[test] + fn test_is_plural() { + assert!(is_plural("cats")); + assert!(is_plural("parties")); + assert!(is_plural("knives")); + assert!(is_plural("potatoes")); + assert!(!is_plural("cat")); + assert!(!is_plural("class")); + assert!(!is_plural("party")); + // assert!(is_plural("alumni")); + // assert!(is_plural("bacteria")); + } +} diff --git a/core/tests/test_extractor.rs b/core/tests/test_extractor.rs new file mode 100644 index 0000000..3e30aaf --- /dev/null +++ b/core/tests/test_extractor.rs @@ -0,0 +1,39 @@ +use openapiv3 as oa; + +use hir::Record; +use libninja_core::extract_spec; +use mir::Ty; + +#[test] +fn test_post_translate() { + let s = include_str!("../../test_specs/deepl.yaml"); + let mut openapi: oa::OpenAPI = serde_yaml::from_str(s).unwrap(); + openapi.paths.paths.retain(|k, _| k == "/translate"); + + let hir = extract_spec(&openapi).unwrap(); + let op = hir.get_operation("translateText").unwrap(); + let Ty::Model(name) = &op.ret else { + panic!("Expected model type"); + }; + let Record::Struct(s) = hir.get_record(name).unwrap() else { + panic!("Expected struct"); + }; + let z = &s.fields["translations"]; + let Ty::Array(ty) = &z.ty else { + // eprintln!("{:?}", s); + // eprintln!("{:?}", hir.get_record("Translation")); + panic!("Expected array: {:?}", z.ty); + }; + dbg!(&hir); + assert!( + matches!(ty.as_ref(), Ty::Model(m) if m == "Translation"), + "Return value of translateText operation should be Vec. Instead: {:?}", + ty + ); + let p = op + .parameters + .iter() + .find(|p| p.name == "target_lang") + .unwrap(); + assert!(matches!(&p.ty, Ty::Model(m) if m == "TargetLanguageText")); +} diff --git a/hir/src/lib.rs b/hir/src/lib.rs index 77ee36e..7d9bc03 100644 --- a/hir/src/lib.rs +++ b/hir/src/lib.rs @@ -8,9 +8,9 @@ use std::string::{String, ToString}; use anyhow::Result; use convert_case::{Case, Casing}; use openapiv3 as oa; -use mir::{Doc, ParamKey}; pub use lang::*; +use mir::{Doc, ParamKey}; use mir::Ty; mod lang; @@ -123,6 +123,12 @@ pub struct Struct { pub docs: Option, } +impl Into for Struct { + fn into(self) -> Record { + Record::Struct(self) + } +} + #[derive(Debug, Clone)] pub struct NewType { pub name: String, @@ -144,6 +150,12 @@ pub struct StrEnum { pub docs: Option, } +impl Into for StrEnum { + fn into(self) -> Record { + Record::Enum(self) + } +} + /// an object type in the HIR #[derive(Debug, Clone)] pub enum Record { @@ -153,6 +165,12 @@ pub enum Record { Enum(StrEnum), } +impl From for Record { + fn from(nt: NewType) -> Self { + Record::NewType(nt) + } +} + impl Record { pub fn name(&self) -> &str { match self { @@ -172,7 +190,7 @@ impl Record { } } - pub fn fields(&self) -> Box + '_> { + pub fn fields(&self) -> Box + '_> { match self { Record::Struct(s) => Box::new(s.fields.values()), Record::Enum(_) => Box::new(empty()), @@ -181,7 +199,7 @@ impl Record { } } - pub fn fields_mut(&mut self) -> Box + '_> { + pub fn fields_mut(&mut self) -> Box + '_> { match self { Record::Struct(s) => Box::new(s.fields.iter_mut().map(|(_, f)| f)), Record::Enum(_) => Box::new(empty()), @@ -205,6 +223,13 @@ impl Record { Record::TypeAlias(_, f) => f.optional, } } + + pub fn as_struct(&self) -> Option<&Struct> { + match self { + Record::Struct(s) => Some(s), + _ => None, + } + } } #[derive(Debug, Clone, Default)] @@ -218,6 +243,17 @@ pub struct HirSpec { pub api_docs_url: Option, } +impl HirSpec { + pub fn insert_schema(&mut self, record: impl Into) { + let record = record.into(); + let name = record.name().to_string(); + if !name.chars().next().unwrap().is_uppercase() { + panic!("Schema name must be uppercase: {}", name); + } + self.schemas.insert(name, record); + } +} + pub enum ServerStrategy { /// No servers were provided, so we pass a base URL BaseUrl, @@ -230,9 +266,15 @@ pub enum ServerStrategy { impl ServerStrategy { pub fn env_var_for_strategy(&self, service_name: &str) -> Option { match self { - ServerStrategy::BaseUrl => Some(format!("{}_BASE_URL", service_name.to_case(Case::ScreamingSnake))), + ServerStrategy::BaseUrl => Some(format!( + "{}_BASE_URL", + service_name.to_case(Case::ScreamingSnake) + )), ServerStrategy::Single(_) => None, - ServerStrategy::Env => Some(format!("{}_ENV", service_name.to_case(Case::ScreamingSnake))), + ServerStrategy::Env => Some(format!( + "{}_ENV", + service_name.to_case(Case::ScreamingSnake) + )), } } } @@ -243,11 +285,16 @@ pub fn qualified_env_var(service: &str, var_name: &str) -> String { impl HirSpec { pub fn get_record(&self, name: &str) -> Result<&Record> { - self.schemas.get(name).ok_or_else(|| anyhow::anyhow!("No record named {}", name)) + self.schemas + .get(name) + .ok_or_else(|| anyhow::anyhow!("No record named {}", name)) } pub fn get_operation(&self, name: &str) -> Result<&Operation> { - self.operations.iter().find(|o| o.name == name).ok_or_else(|| anyhow::anyhow!("No operation named {}", name)) + self.operations + .iter() + .find(|o| o.name == name) + .ok_or_else(|| anyhow::anyhow!("No operation named {}", name)) } pub fn server_strategy(&self) -> ServerStrategy { @@ -293,14 +340,19 @@ impl HirSpec { } pub fn has_basic_auth(&self) -> bool { - self.security.iter().any(|s| matches!(s, AuthStrategy::Token(_))) + self.security + .iter() + .any(|s| matches!(s, AuthStrategy::Token(_))) } pub fn oauth2_auth(&self) -> Option<&Oauth2Auth> { - self.security.iter().filter_map(|s| match s { - AuthStrategy::OAuth2(o) => Some(o), - _ => None, - }).next() + self.security + .iter() + .filter_map(|s| match s { + AuthStrategy::OAuth2(o) => Some(o), + _ => None, + }) + .next() } } @@ -390,31 +442,29 @@ impl Operation { example: None, }] } - _ => { - self.parameters - .iter() - .filter(|p| !p.optional).cloned() - .collect() - } + _ => self + .parameters + .iter() + .filter(|p| !p.optional) + .cloned() + .collect(), } } pub fn required_struct(&self, sourcegen: Language) -> Struct { let fields = match sourcegen { - Language::Typescript => { - self.parameters - .iter() - .map(|p| (p.name.clone(), p.into())) - .collect() - } - Language::Rust | Language::Golang => { - self.parameters - .iter() - .filter(|p| !p.optional) - .map(|p| (p.name.clone(), p.into())) - .collect() - } - _ => unimplemented!() + Language::Typescript => self + .parameters + .iter() + .map(|p| (p.name.clone(), p.into())) + .collect(), + Language::Rust | Language::Golang => self + .parameters + .iter() + .filter(|p| !p.optional) + .map(|p| (p.name.clone(), p.into())) + .collect(), + _ => unimplemented!(), }; Struct { nullable: false, @@ -448,4 +498,4 @@ impl From<&Parameter> for HirField { flatten: false, } } -} \ No newline at end of file +} diff --git a/libninja/Cargo.toml b/libninja/Cargo.toml index b363187..7b5846c 100644 --- a/libninja/Cargo.toml +++ b/libninja/Cargo.toml @@ -39,7 +39,7 @@ strum = "0.26.1" semver = "1.0.17" indexmap = "2.0" libninja_macro = { path = "../macro" } -libninja_core = { path = "../core" } +ln_core = { path = "../core", package = "libninja_core" } libninja_mir = { path = "../mir" } libninja_hir = { path = "../hir" } libninja_mir_rust = { path = "../mir_rust" } diff --git a/libninja/Justfile b/libninja/Justfile new file mode 100644 index 0000000..d52647c --- /dev/null +++ b/libninja/Justfile @@ -0,0 +1,13 @@ +set export + +run: + cargo run + +test *ARGS: + cargo test -- $ARGS + +build: + cargo build + +install: + cargo install --path . diff --git a/libninja/src/command/meta.rs b/libninja/src/command/meta.rs index 0a5f57a..43412f7 100644 --- a/libninja/src/command/meta.rs +++ b/libninja/src/command/meta.rs @@ -1,12 +1,14 @@ -use std::collections::HashMap; use std::path::PathBuf; + use anyhow::Result; use clap::Args; -use crate::read_spec; -use ln_core::child_schemas::ChildSchemas; + +use hir::Language; use ln_core::extract_spec; use ln_core::extractor::add_operation_models; -use hir::Language; + +use crate::read_spec; +// use ln_core::child_schemas::ChildSchemas; use crate::rust::calculate_extras; #[derive(Args, Debug)] @@ -28,11 +30,11 @@ impl Meta { pub fn run(self) -> Result<()> { let path = PathBuf::from(self.spec_filepath); let spec = read_spec(&path)?; - let mut schema_lookup = HashMap::new(); - spec.add_child_schemas(&mut schema_lookup); - for (name, schema) in schema_lookup { - println!("{}", name); - } + // let mut schema_lookup = HashMap::new(); + // spec.add_child_schemas(&mut schema_lookup); + // for (name, schema) in schema_lookup { + // println!("{}", name); + // } let spec = extract_spec(&spec)?; let spec = add_operation_models(Language::Rust, spec)?; let extras = calculate_extras(&spec); @@ -41,4 +43,3 @@ impl Meta { Ok(()) } } - diff --git a/libninja/src/lib.rs b/libninja/src/lib.rs index 7d31f56..70af1f6 100644 --- a/libninja/src/lib.rs +++ b/libninja/src/lib.rs @@ -5,30 +5,29 @@ use std::collections::HashMap; use std::fs::File; use std::path::Path; -use std::process::ExitCode; pub use ::openapiv3::OpenAPI; use anyhow::{anyhow, Context, Result}; pub use openapiv3; use openapiv3::VersionedOpenAPI; use serde::{Deserialize, Serialize}; -use serde_yaml::Value; use commercial::*; -use ln_core::{ConfigFlags, PackageConfig, OutputConfig}; -use ln_core::extractor::{extract_api_operations, extract_spec}; +use hir::Language; +use ln_core::{OutputConfig, PackageConfig}; use ln_core::extractor::add_operation_models; -use ln_core::fs::open; -use hir::{Language, HirSpec}; +use ln_core::extractor::extract_spec; -pub mod custom; -pub mod rust; pub mod command; mod commercial; +pub mod custom; +pub mod rust; pub fn read_spec(path: &Path) -> Result { let file = File::open(path).map_err(|_| anyhow!("{:?}: File not found.", path))?; - let ext = path.extension().map(|s| s.to_str().expect("Extension isn't utf8")) + let ext = path + .extension() + .map(|s| s.to_str().expect("Extension isn't utf8")) .unwrap_or_else(|| "yaml"); let openapi: VersionedOpenAPI = match ext { "yaml" => serde_yaml::from_reader(file)?, @@ -67,26 +66,41 @@ pub fn generate_examples( for operation in &spec.operations { let rust = { let generator = Language::Rust; - let opt = PackageConfig { language: generator, ..opt.clone() }; + let opt = PackageConfig { + language: generator, + ..opt.clone() + }; let spec = add_operation_models(generator, spec.clone())?; rust::generate_example(operation, &opt, &spec)? }; let python = { - let opt = PackageConfig { language: Language::Python, ..opt.clone() }; + let opt = PackageConfig { + language: Language::Python, + ..opt.clone() + }; python::generate_sync_example(operation, &opt, &spec)? }; let python_async = { - let opt = PackageConfig { language: Language::Python, ..opt.clone() }; + let opt = PackageConfig { + language: Language::Python, + ..opt.clone() + }; python::generate_async_example(operation, &opt, &spec)? }; let typescript = { let generator = Language::Rust; - let opt = PackageConfig { language: generator, ..opt.clone() }; + let opt = PackageConfig { + language: generator, + ..opt.clone() + }; let spec = add_operation_models(generator, spec.clone())?; typescript::generate_example(operation, &opt, &spec)? }; let go = { - let opt = PackageConfig { language: Language::Golang, ..opt.clone() }; + let opt = PackageConfig { + language: Language::Golang, + ..opt.clone() + }; go::generate_example(operation, &opt, &spec)? }; let examples = Examples { diff --git a/libninja/src/rust/codegen.rs b/libninja/src/rust/codegen.rs index 34a4a67..eb277ff 100644 --- a/libninja/src/rust/codegen.rs +++ b/libninja/src/rust/codegen.rs @@ -1,16 +1,16 @@ use convert_case::Casing; use proc_macro2::TokenStream; use quote::{quote, TokenStreamExt}; -use mir::{Ident, NewType, Ty}; -pub use typ::*; + pub use example::*; pub use ident::*; +use mir::Ident; use mir_rust::{ToRustCode, ToRustIdent}; +pub use ty::*; mod example; mod ident; -mod typ; - +mod ty; #[cfg(test)] mod tests { @@ -59,14 +59,10 @@ mod tests { ); let import = Import::package("foo_bar"); - assert_eq!( - import.to_rust_code().to_string(), - "use foo_bar ;" - ); + assert_eq!(import.to_rust_code().to_string(), "use foo_bar ;"); } } - pub fn serde_rename(one: &str, two: &Ident) -> TokenStream { if one != &two.0 { quote!(#[serde(rename = #one)]) diff --git a/libninja/src/rust/codegen/example.rs b/libninja/src/rust/codegen/example.rs index df3318e..f2bf475 100644 --- a/libninja/src/rust/codegen/example.rs +++ b/libninja/src/rust/codegen/example.rs @@ -5,11 +5,11 @@ use quote::quote; use hir::{HirField, HirSpec, Language, NewType, Operation, Parameter, Record, StrEnum, Struct}; use ln_macro::rfunction; use mir::{File, Import, Ty}; +use mir_rust::format_code; use crate::PackageConfig; use crate::rust::codegen::{ToRustCode, ToRustType}; use crate::rust::codegen::ToRustIdent; -use mir_rust::format_code; pub trait ToRustExample { fn to_rust_example(&self, spec: &HirSpec) -> anyhow::Result; @@ -21,32 +21,52 @@ impl ToRustExample for Parameter { } } - -pub fn generate_example(operation: &Operation, opt: &PackageConfig, spec: &HirSpec) -> anyhow::Result { +pub fn generate_example( + operation: &Operation, + opt: &PackageConfig, + spec: &HirSpec, +) -> anyhow::Result { let args = operation.function_args(Language::Rust); - let declarations = args.iter().map(|p| { - let ident = p.name.to_rust_ident(); - let value = to_rust_example_value(&p.ty, &p.name, spec, true)?; - Ok(quote! { - let #ident = #value; + let declarations = args + .iter() + .map(|p| { + let ident = p.name.to_rust_ident(); + let value = to_rust_example_value(&p.ty, &p.name, spec, true)?; + Ok(quote! { + let #ident = #value; + }) }) - }).collect::, anyhow::Error>>()?; + .collect::, anyhow::Error>>()?; let fn_args = args.iter().map(|p| p.name.to_rust_ident()); - let optionals = operation.optional_args().into_iter().map(|p| { - let ident = p.name.to_rust_ident(); - let value = to_rust_example_value(&p.ty, &p.name, spec, true)?; - Ok(quote! { - .#ident(#value) + let optionals = operation + .optional_args() + .into_iter() + .map(|p| { + let ident = p.name.to_rust_ident(); + let value = to_rust_example_value(&p.ty, &p.name, spec, true)?; + Ok(quote! { + .#ident(#value) + }) }) - }).collect::, anyhow::Error>>()?; - let qualified_client = format!("{}::{}", opt.package_name, opt.client_name().to_rust_struct()); + .collect::, anyhow::Error>>()?; + let qualified_client = format!( + "{}::{}", + opt.package_name, + opt.client_name().to_rust_struct() + ); let mut imports = vec![ Import::package(&qualified_client), Import::package(&format!("{}::model::*", opt.package_name)), ]; if operation.use_required_struct(Language::Rust) { - let struct_name = operation.required_struct_name().to_rust_struct().to_string(); - imports.push(Import::package(&format!("{}::request::{}", opt.package_name, struct_name))); + let struct_name = operation + .required_struct_name() + .to_rust_struct() + .to_string(); + imports.push(Import::package(&format!( + "{}::request::{}", + opt.package_name, struct_name + ))); } let operation = operation.name.to_rust_ident(); let client = opt.client_name().to_rust_struct(); @@ -70,7 +90,12 @@ pub fn generate_example(operation: &Operation, opt: &PackageConfig, spec: &HirSp Ok(format_code(code)) } -pub fn to_rust_example_value(ty: &Ty, name: &str, spec: &HirSpec, use_ref_value: bool) -> anyhow::Result { +pub fn to_rust_example_value( + ty: &Ty, + name: &str, + spec: &HirSpec, + use_ref_value: bool, +) -> anyhow::Result { let s = match ty { Ty::String => { let s = format!("your {}", name.to_case(Case::Lower)); @@ -100,28 +125,46 @@ pub fn to_rust_example_value(ty: &Ty, name: &str, spec: &HirSpec, use_ref_value: let record = spec.get_record(model)?; let force_ref = model.ends_with("Required"); match record { - Record::Struct(Struct { name: _name, fields, nullable, docs: _docs }) => { - let fields = fields.iter().map(|(name, field)| { - let not_ref = !force_ref || field.optional; - let mut value = to_rust_example_value(&field.ty, name, spec, !not_ref)?; - let name = name.to_rust_ident(); - if field.optional { - value = quote!(Some(#value)); - } - Ok(quote!(#name: #value)) - }).collect::, anyhow::Error>>()?; + Record::Struct(Struct { + name: _name, + fields, + nullable, + docs: _docs, + }) => { + let fields = fields + .iter() + .map(|(name, field)| { + let not_ref = !force_ref || field.optional; + let mut value = to_rust_example_value(&field.ty, name, spec, !not_ref)?; + let name = name.to_rust_ident(); + if field.optional { + value = quote!(Some(#value)); + } + Ok(quote!(#name: #value)) + }) + .collect::, anyhow::Error>>()?; let model = model.to_rust_struct(); quote!(#model{#(#fields),*}) } - Record::NewType(NewType { name, fields, docs: _docs }) => { - let fields = fields.iter().map(|f| { - to_rust_example_value(&f.ty, name, spec, false) - }).collect::, _>>()?; + Record::NewType(NewType { + name, + fields, + docs: _docs, + }) => { + let fields = fields + .iter() + .map(|f| to_rust_example_value(&f.ty, name, spec, false)) + .collect::, _>>()?; let name = name.to_rust_struct(); quote!(#name(#(#fields),*)) } - Record::Enum(StrEnum { name, variants, docs: _docs }) => { + Record::Enum(StrEnum { + name, + variants, + docs: _docs, + }) => { let variant = variants.first().unwrap(); + eprintln!("variant: {}", variant); let variant = variant.to_rust_struct(); let model = model.to_rust_struct(); quote!(#model::#variant) @@ -138,10 +181,10 @@ pub fn to_rust_example_value(ty: &Ty, name: &str, spec: &HirSpec, use_ref_value: } } Ty::Unit => quote!(()), - Ty::Any => quote!(serde_json::json!({})), + Ty::Any(_) => quote!(serde_json::json!({})), Ty::Date { .. } => quote!(chrono::Utc::now().date_naive()), Ty::DateTime { .. } => quote!(chrono::Utc::now()), - Ty::Currency { .. } => quote!(rust_decimal_macros::dec!(100.01)) + Ty::Currency { .. } => quote!(rust_decimal_macros::dec!(100.01)), }; Ok(s) } diff --git a/libninja/src/rust/codegen/typ.rs b/libninja/src/rust/codegen/ty.rs similarity index 88% rename from libninja/src/rust/codegen/typ.rs rename to libninja/src/rust/codegen/ty.rs index d94622c..1f4e44e 100644 --- a/libninja/src/rust/codegen/typ.rs +++ b/libninja/src/rust/codegen/ty.rs @@ -1,7 +1,9 @@ use proc_macro2::TokenStream; use quote::quote; + use hir::HirSpec; use mir::Ty; + use crate::rust::codegen::ToRustIdent; use crate::rust::lower_hir::HirFieldExt; @@ -25,11 +27,9 @@ impl ToRustType for Ty { let inner = inner.to_rust_type(); quote!(Vec<#inner>) } - Ty::Model(inner, ..) => { - inner.to_rust_struct().into() - } + Ty::Model(inner, ..) => inner.to_rust_struct().into(), Ty::Unit => quote!(()), - Ty::Any => quote!(serde_json::Value), + Ty::Any(_) => quote!(serde_json::Value), Ty::Date { .. } => quote!(chrono::NaiveDate), Ty::DateTime { .. } => quote!(chrono::DateTime), Ty::Currency { .. } => quote!(rust_decimal::Decimal), @@ -50,11 +50,9 @@ impl ToRustType for Ty { self.to_rust_type() } } - Ty::Model(inner, ..) => { - inner.to_rust_struct().into() - } + Ty::Model(inner, ..) => inner.to_rust_struct().into(), Ty::Unit => quote!(()), - Ty::Any => quote!(serde_json::Value), + Ty::Any(_) => quote!(serde_json::Value), Ty::Date { .. } => quote!(chrono::NaiveDate), Ty::DateTime { .. } => quote!(chrono::DateTime), Ty::Currency { .. } => quote!(rust_decimal::Decimal), @@ -82,7 +80,7 @@ impl ToRustType for Ty { model.fields().all(|f| f.implements_default(spec)) } Ty::Unit => true, - Ty::Any => true, + Ty::Any(_) => true, Ty::Date { .. } => true, Ty::DateTime => true, Ty::Currency { .. } => true, @@ -95,15 +93,13 @@ impl ToRustType for Ty { Ty::Integer { .. } => true, Ty::Float => true, Ty::Boolean => true, - Ty::Array(inner) => { - inner.implements_dummy(spec) - } + Ty::Array(inner) => inner.implements_dummy(spec), Ty::Model(name) => { let model = spec.get_record(name.as_str()).expect("Model not found"); model.fields().all(|f| f.ty.implements_dummy(spec)) } Ty::Unit => true, - Ty::Any => false, + Ty::Any(_) => false, Ty::Date { .. } => true, Ty::DateTime => true, Ty::Currency { .. } => true, diff --git a/libninja/src/rust/lower_hir.rs b/libninja/src/rust/lower_hir.rs index f2839ce..a08bfcb 100644 --- a/libninja/src/rust/lower_hir.rs +++ b/libninja/src/rust/lower_hir.rs @@ -1,19 +1,18 @@ use std::collections::BTreeSet; -use cargo_toml::Package; use convert_case::Casing; -use proc_macro2::{extra, TokenStream}; +use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use hir::{HirField, HirSpec, NewType, Record, StrEnum, Struct}; use ln_core::{ConfigFlags, PackageConfig}; -use mir::{import, Field, File, Ident, Import, Visibility}; +use mir::{Field, File, Ident, import, Import, Visibility}; use mir::{DateSerialization, DecimalSerialization, IntegerSerialization, Ty}; +use mir_rust::{sanitize_filename, ToRustIdent}; +use mir_rust::ToRustCode; use crate::rust::codegen; use crate::rust::codegen::ToRustType; -use mir_rust::ToRustCode; -use mir_rust::{sanitize_filename, ToRustIdent}; pub trait FieldExt { fn decorators(&self, name: &str, config: &ConfigFlags) -> Vec; @@ -47,7 +46,7 @@ impl FieldExt for HirField { decorators.push(quote! { #[serde(default, skip_serializing_if = "Vec::is_empty")] }); - } else if matches!(self.ty, Ty::Any) { + } else if matches!(self.ty, Ty::Any(_)) { decorators.push(quote! { #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] }); @@ -197,17 +196,16 @@ impl HirFieldExt for HirField { /// Generate a model.rs file that just imports from dependents. pub fn generate_model_rs(spec: &HirSpec, config: &ConfigFlags) -> File { - let imports = spec - .schemas - .keys() + let it = spec.schemas.keys(); + let imports = it + .clone() .map(|name: &String| { let fname = sanitize_filename(&name); Import::new(&fname, vec!["*"]).public() }) .collect(); - let code = spec - .schemas - .keys() + let code = it + .clone() .map(|name| { let name = Ident(sanitize_filename(name)); quote! { @@ -307,10 +305,15 @@ pub fn create_sumtype_struct( } } +// pub fn is_restricted(s: &str) -> bool { +// ["self"].contains(&s) +// } + fn create_enum_struct(e: &StrEnum, derives: &Vec) -> TokenStream { let enums = e.variants.iter().filter(|s| !s.is_empty()).map(|s| { let original_name = s.to_string(); let mut s = original_name.clone(); + eprintln!("enum variant: {}", s); if !s.is_empty() && s.chars().next().unwrap().is_numeric() { s = format!("{}{}", e.name, s); } @@ -364,10 +367,10 @@ pub fn create_typealias(name: &str, schema: &HirField) -> TokenStream { } } -pub fn create_struct(record: &Record, config: &PackageConfig, spec: &HirSpec) -> TokenStream { +pub fn create_struct(record: &Record, config: &PackageConfig, hir: &HirSpec) -> TokenStream { match record { - Record::Struct(s) => create_sumtype_struct(s, &config.config, spec, &config.derives), - Record::NewType(nt) => create_newtype_struct(nt, spec, &config.derives), + Record::Struct(s) => create_sumtype_struct(s, &config.config, hir, &config.derives), + Record::NewType(nt) => create_newtype_struct(nt, hir, &config.derives), Record::Enum(en) => create_enum_struct(en, &config.derives), Record::TypeAlias(name, field) => create_typealias(name, field), } @@ -388,11 +391,8 @@ pub fn derives_to_tokens(derives: &Vec) -> TokenStream { #[cfg(test)] mod tests { - use std::path::PathBuf; - use hir::HirField; use mir::Ty; - use mir_rust::format_code; use super::*; diff --git a/libninja/tests/all_of/main.rs b/libninja/tests/all_of/main.rs index 6eedf61..9ce4da6 100644 --- a/libninja/tests/all_of/main.rs +++ b/libninja/tests/all_of/main.rs @@ -1,24 +1,16 @@ +use std::path::PathBuf; + use openapiv3::{OpenAPI, Schema}; use pretty_assertions::assert_eq; -use std::path::PathBuf; +use serde_yaml::from_str; use hir::{HirSpec, Record}; -use ln_core::extractor::extract_records; -/// Tests that the `allOf` keyword is handled correctly. +use libninja::rust::lower_hir::create_struct; use ln_core::{ConfigFlags, PackageConfig}; +use ln_core::extractor::{extract_schema, extract_without_treeshake}; +use mir_rust::format_code; -const TRANSACTION: &str = include_str!("transaction.yaml"); -const TRANSACTION_RS: &str = include_str!("transaction.rs"); - -const RESTRICTION_BACS: &str = include_str!("restriction_bacs.yaml"); -const RESTRICTION_BACS_RS: &str = include_str!("restriction_bacs.rs"); - -fn record_for_schema(name: &str, schema: &str, spec: &OpenAPI) -> Record { - let schema = serde_yaml::from_str::(schema).unwrap(); - ln_core::extractor::create_record(name, &schema, spec) -} - -fn formatted_code(record: Record, spec: &HirSpec) -> String { +fn formatted_code(record: &Record, spec: &HirSpec) -> String { let config = PackageConfig { package_name: "test".to_string(), service_name: "service".to_string(), @@ -28,26 +20,28 @@ fn formatted_code(record: Record, spec: &HirSpec) -> String { dest: PathBuf::new(), derives: vec![], }; - let code = libninja::rust::lower_hir::create_struct(&record, &config, spec); - mir_rust::format_code(code) + let code = create_struct(&record, &config, spec); + format_code(code) } #[test] fn test_transaction() { let mut spec = OpenAPI::default(); - spec.schemas.insert("TransactionBase", Schema::new_object()); - spec.schemas.insert("TransactionCode", Schema::new_string()); - spec.schemas - .insert("PersonalFinanceCategory", Schema::new_string()); - spec.schemas - .insert("TransactionCounterparty", Schema::new_string()); - - let mut result = HirSpec::default(); - extract_records(&spec, &mut result).unwrap(); - let record = record_for_schema("Transaction", TRANSACTION, &spec); - let code = formatted_code(record, &result); + let s = &mut spec.schemas; + s.insert("TransactionBase", Schema::new_object()); + s.insert("TransactionCode", Schema::new_string()); + s.insert("PersonalFinanceCategory", Schema::new_string()); + s.insert("TransactionCounterparty", Schema::new_string()); + + let mut hir = extract_without_treeshake(&spec).unwrap(); + dbg!(&hir.schemas); + let schema = include_str!("transaction.yaml"); + let schema: Schema = from_str(schema).unwrap(); + extract_schema("Transaction", &schema, &spec, &mut hir); + let record = hir.get_record("Transaction").unwrap(); + let code = formatted_code(record, &hir); println!("{}", code); - assert_eq!(code, TRANSACTION_RS); + assert_eq!(code, include_str!("transaction.rs")); } #[test] @@ -55,11 +49,12 @@ fn test_nullable_doesnt_deref() { let mut spec = OpenAPI::default(); spec.schemas.insert("RecipientBACS", Schema::new_object()); - let record = record_for_schema( - "PaymentInitiationOptionalRestrictionBacs", - RESTRICTION_BACS, - &spec, - ); - let code = formatted_code(record, &HirSpec::default()); - assert_eq!(code, RESTRICTION_BACS_RS); + let mut hir = HirSpec::default(); + let schema = include_str!("restriction_bacs.yaml"); + let schema: Schema = from_str(schema).unwrap(); + let name = "PaymentInitiationOptionalRestrictionBacs"; + extract_schema(name, &schema, &spec, &mut hir); + let record = hir.get_record(name).unwrap(); + let code = formatted_code(record, &hir); + assert_eq!(code, include_str!("restriction_bacs.rs")); } diff --git a/libninja/tests/basic/main.rs b/libninja/tests/basic/main.rs index 102e8c6..c675850 100644 --- a/libninja/tests/basic/main.rs +++ b/libninja/tests/basic/main.rs @@ -1,38 +1,26 @@ -use std::fs::File; use std::path::PathBuf; use std::str::FromStr; -use anyhow::Result; use openapiv3::OpenAPI; use pretty_assertions::assert_eq; +use serde_yaml::from_str; -use hir::{HirSpec, Language}; -use libninja::{generate_library, rust}; -use ln_core::extractor::{extract_api_operations, extract_inputs, extract_spec}; +use hir::Language; +use libninja::generate_library; +use libninja::rust::generate_example; use ln_core::{OutputConfig, PackageConfig}; - -const BASIC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/spec/basic.yaml"); -const RECURLY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/spec/recurly.yaml"); +use ln_core::extractor::extract_spec; const EXAMPLE: &str = include_str!("link_create_token.rs"); -#[test] -pub fn test_required_args() { - let yaml = File::open(BASIC).unwrap(); - let spec: OpenAPI = serde_yaml::from_reader(yaml).unwrap(); - let (operation, path) = spec.get_operation("linkTokenCreate").unwrap(); - let inputs = extract_inputs(&operation, path, &spec).unwrap(); - assert_eq!(inputs[8].name, "user_token"); - assert_eq!(inputs[8].optional, true); -} +const BASIC: &str = include_str!("../../../test_specs/basic.yaml"); +const RECURLY: &str = include_str!("../../../test_specs/recurly.yaml"); #[test] -fn test_generate_example() -> Result<()> { - let yaml = File::open(BASIC).unwrap(); - let spec: OpenAPI = serde_yaml::from_reader(yaml).unwrap(); - // let operation = spec.get_operation("linkTokenCreate").unwrap(); +fn test_generate_example() { + let spec: OpenAPI = from_str(BASIC).unwrap(); - let opt = PackageConfig { + let config = PackageConfig { package_name: "plaid".to_string(), service_name: "Plaid".to_string(), language: Language::Rust, @@ -41,26 +29,23 @@ fn test_generate_example() -> Result<()> { dest: PathBuf::from_str("..").unwrap(), derives: vec![], }; - let mut result = HirSpec::default(); - extract_api_operations(&spec, &mut result).unwrap(); - let operation = result - .operations - .iter() - .find(|o| o.name == "linkTokenCreate") - .unwrap(); - - let spec = extract_spec(&spec).unwrap(); - let example = rust::generate_example(&operation, &opt, &spec)?; + let hir = extract_spec(&spec).unwrap(); + let op = hir.get_operation("linkTokenCreate").unwrap(); + let example = generate_example(&op, &config, &hir).unwrap(); assert_eq!(example, EXAMPLE); - Ok(()) } #[test] -pub fn test_build_full_library_recurly() -> Result<()> { - let yaml = File::open(RECURLY).unwrap(); - let temp = tempfile::tempdir()?; +pub fn test_build_full_library_recurly() { + tracing_subscriber::fmt() + .without_time() + .with_max_level(tracing::Level::DEBUG) + .with_writer(std::io::stdout) + .init(); + let spec: OpenAPI = from_str(RECURLY).unwrap(); + + let temp = tempfile::tempdir().unwrap(); - let spec: OpenAPI = serde_yaml::from_reader(yaml).unwrap(); let opts = OutputConfig { dest_path: temp.path().to_path_buf(), build_examples: false, @@ -72,5 +57,5 @@ pub fn test_build_full_library_recurly() -> Result<()> { version: None, derive: vec![], }; - generate_library(spec, opts) + generate_library(spec, opts).unwrap(); } diff --git a/libninja/tests/regression/main.rs b/libninja/tests/regression/main.rs index bbb4c09..818a956 100644 --- a/libninja/tests/regression/main.rs +++ b/libninja/tests/regression/main.rs @@ -1,27 +1,26 @@ use openapiv3::{OpenAPI, Schema}; +use serde_yaml::from_str; -use hir::{HirSpec, Record}; +use hir::HirSpec; use libninja::rust::lower_hir::StructExt; - -const LINK_TOKEN_CREATE: &str = include_str!("link_token_create.yaml"); - - -fn record_for_schema(name: &str, schema: &str, spec: &OpenAPI) -> Record { - let schema = serde_yaml::from_str::(schema).unwrap(); - let mut record = ln_core::extractor::create_record(name, &schema, spec); - record.clear_docs(); - record -} - +use ln_core::extractor::extract_schema; #[test] fn test_link_token_create() { let mut spec = OpenAPI::default(); + let mut hir = HirSpec::default(); + + spec.schemas.insert("UserName", Schema::new_string()); spec.schemas.insert("UserAddress", Schema::new_object()); spec.schemas.insert("UserIDNumber", Schema::new_string()); - let record = record_for_schema("LinkTokenCreateRequestUser", LINK_TOKEN_CREATE, &spec); - let Record::Struct(struc) = record else { - panic!("expected struct"); - }; - assert!(struc.implements_default(&HirSpec::default())); -} \ No newline at end of file + + let schema = include_str!("link_token_create.yaml"); + let schema: Schema = from_str(schema).unwrap(); + extract_schema("LinkTokenCreateRequestUser", &schema, &spec, &mut hir); + let s = hir + .get_record("LinkTokenCreateRequestUser") + .unwrap() + .as_struct() + .unwrap(); + assert!(s.implements_default(&hir)); +} diff --git a/mir/Cargo.toml b/mir/Cargo.toml index 2c0d954..52ed54e 100644 --- a/mir/Cargo.toml +++ b/mir/Cargo.toml @@ -9,4 +9,5 @@ path = "src/lib.rs" [dependencies] quote = "1.0.29" -proc-macro2 = "1.0.63" \ No newline at end of file +proc-macro2 = "1.0.63" +openapiv3-extended = "6.0.0" \ No newline at end of file diff --git a/mir/src/ident.rs b/mir/src/ident.rs index 99ad465..919cc71 100644 --- a/mir/src/ident.rs +++ b/mir/src/ident.rs @@ -6,14 +6,6 @@ use quote::TokenStreamExt; #[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] pub struct Ident(pub String); -impl Ident { - pub fn validate(&self) { - if self.0.contains(',') { - panic!("Ident cannot contain a comma: {}", self.0); - } - } -} - impl Ident { pub fn new(s: &'static str) -> Self { Ident(s.into()) @@ -40,14 +32,20 @@ impl PartialEq<&str> for Ident { impl quote::ToTokens for Ident { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - tokens.append(proc_macro2::Ident::new(&self.0, proc_macro2::Span::call_site())) + tokens.append(proc_macro2::Ident::new( + &self.0, + proc_macro2::Span::call_site(), + )) } } impl From for proc_macro2::TokenStream { fn from(val: Ident) -> Self { let mut tok = proc_macro2::TokenStream::new(); - tok.append(proc_macro2::Ident::new(&val.0, proc_macro2::Span::call_site())); + tok.append(proc_macro2::Ident::new( + &val.0, + proc_macro2::Span::call_site(), + )); tok } } diff --git a/mir/src/ty.rs b/mir/src/ty.rs index 61b551c..2529f0b 100644 --- a/mir/src/ty.rs +++ b/mir/src/ty.rs @@ -1,3 +1,5 @@ +use openapiv3 as oa; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum DateSerialization { Iso8601, @@ -19,9 +21,7 @@ pub enum IntegerSerialization { #[derive(Debug, Clone)] pub enum Ty { String, - Integer { - serialization: IntegerSerialization, - }, + Integer { serialization: IntegerSerialization }, Float, Boolean, Array(Box), @@ -31,12 +31,12 @@ pub enum Ty { Date { serialization: DateSerialization }, DateTime, Currency { serialization: DecimalSerialization }, - Any, + Any(Option), } impl Default for Ty { fn default() -> Self { - Ty::Any + Ty::Any(None) } } @@ -74,7 +74,7 @@ impl Ty { Ty::Boolean => true, Ty::Array(_) => false, Ty::Model(_) => false, - Ty::Any => false, + Ty::Any(_) => false, Ty::Unit => true, Ty::Date { .. } => true, Ty::Currency { .. } => true, @@ -83,6 +83,9 @@ impl Ty { } pub fn model(s: &str) -> Self { + if s.contains('(') { + panic!("Model names should not contain parens: {}", s); + } Ty::Model(s.to_string()) } } diff --git a/mir_rust/src/lib.rs b/mir_rust/src/lib.rs index 3c01c9a..895434c 100644 --- a/mir_rust/src/lib.rs +++ b/mir_rust/src/lib.rs @@ -5,11 +5,11 @@ use regex::{Captures, Regex}; use mir::{Doc, Ident, Literal, ParamKey, Visibility}; -mod file; mod class; -mod import; -mod function; mod r#enum; +mod file; +mod function; +mod import; /// Use this for codegen structs: Function, Class, etc. pub trait ToRustCode { @@ -40,7 +40,6 @@ impl ToRustCode for Visibility { } } - impl ToRustCode for Option { fn to_rust_code(self) -> TokenStream { match self { @@ -72,7 +71,6 @@ impl ToRustCode for ParamKey { } } - pub trait ToRustIdent { fn to_rust_struct(&self) -> Ident; fn to_rust_ident(&self) -> Ident; @@ -150,6 +148,9 @@ fn sanitize_struct(s: impl AsRef) -> Ident { if is_restricted(&s) { s += "Struct" } + if s == "Self" { + s += "_"; + } assert_valid_ident(&s, &original); Ident(s) } @@ -159,6 +160,9 @@ pub fn is_restricted(s: &str) -> bool { } fn assert_valid_ident(s: &str, original: &str) { + if s.contains('(') { + panic!("Parentheses in identifier: {}", original) + } if s.chars().next().map(|c| c.is_numeric()).unwrap_or_default() { panic!("Numeric identifier: {}", original) } @@ -177,7 +181,10 @@ mod tests { #[test] fn test_filename() { let s = "SdAddress.contractor1099"; - assert_eq!(String::from(s).to_rust_ident().0, "sd_address_contractor1099"); + assert_eq!( + String::from(s).to_rust_ident().0, + "sd_address_contractor1099" + ); assert_eq!(sanitize_filename(s), "sd_address_contractor1099"); } } diff --git a/libninja/tests/spec/basic.yaml b/test_specs/basic.yaml similarity index 100% rename from libninja/tests/spec/basic.yaml rename to test_specs/basic.yaml diff --git a/test_specs/deepl.yaml b/test_specs/deepl.yaml new file mode 100644 index 0000000..8b4d76f --- /dev/null +++ b/test_specs/deepl.yaml @@ -0,0 +1,1981 @@ +openapi: 3.0.3 +info: + title: DeepL API Documentation + description: The DeepL API provides programmatic access to DeepL’s machine translation + technology. + termsOfService: https://www.deepl.com/pro-license/ + contact: + name: DeepL - Contact us + url: https://www.deepl.com/contact-us + version: 2.13.0 +externalDocs: + description: DeepL Pro - Plans and pricing + url: https://www.deepl.com/pro#developer?cta=header-prices/ +servers: +- url: https://api.deepl.com/v2 + description: DeepL API Pro +- url: https://api-free.deepl.com/v2 + description: DeepL API Free +tags: +- name: TranslateText + description: |- + The text-translation API currently consists of a single endpoint, `translate`, which is described below. + + To learn more about context in DeepL API translations, we recommend [this article](https://www.deepl.com/docs-api/general/working-with-context). +- name: TranslateDocuments + description: |- + The document translation API allows you to translate whole documents and supports the following file types and extensions: + * `docx` - Microsoft Word Document + * `pptx` - Microsoft PowerPoint Document + * `xlsx` - Microsoft Excel Document + * `pdf` - Portable Document Format + * `htm / html` - HTML Document + * `txt` - Plain Text Document + * `xlf / xliff` - XLIFF Document, version 2.1 + + Please note that with every submitted document of type .pptx, .docx, .xlsx, or .pdf, + you are billed a minimum of 50,000 characters with the DeepL API plan, + no matter how many characters are included in the document. + + + Translating a document usually involves three types of HTTP requests: + - [upload](https://www.deepl.com/docs-api/documents/translate-document) the document to be translated, + - periodically [check the status](https://www.deepl.com/docs-api/documents/get-document-status) of the document translation, + - once the status call reports `done`, [download](https://www.deepl.com/docs-api/documents/download-document) the translated document. + + + To learn more about context in DeepL API translations, we recommend [this article](https://www.deepl.com/docs-api/general/working-with-context). +- name: ManageGlossaries + description: |- + The *glossary* functions allow you to create, inspect, and delete glossaries. + Glossaries created with the glossary function can be used in translate requests by specifying the + `glossary_id` parameter. + If you encounter issues, please let us know at support@DeepL.com. + + The DeepL API supports glossaries in any combination of two languages from the following list, enabling a total of + 66 possible glossary language pairs: + + - DE (German) + - EN (English) + - ES (Spanish) + - FR (French) + - IT (Italian) + - JA (Japanese) + - KO (Korean) + - NL (Dutch) + - PL (Polish) + - PT (Portuguese) + - RU (Russian) + - ZH (Chinese) + + The maximum size limit for a glossary is 10 MiB = 10485760 bytes and each source/target text, + as well as the name of the glossary, is limited to 1024 UTF-8 bytes. + A total of 1000 glossaries are allowed per account. + + When creating a glossary with target language `EN` or `PT`, it's not necessary to specify a variant (e.g. `EN-US`, `EN-GB`, `PT-PT` or `PT-BR`). + Glossaries with target language `EN` can be used in translations with either English variant. + Similarly `PT` glossaries can be used in translations with either Portuguese variant. + + + Glossaries created via the DeepL API are distinct from glossaries created via the DeepL website and DeepL apps. + This means API glossaries cannot be used on the website and vice versa. + + + + Note that glossaries are immutable: once created, the glossary entries for a given glossary ID cannot be modified. + + As a workaround for effectively editable glossaries, we suggest to identify glossaries by name instead of ID in your application + and then use the following procedure for modifications: + - [download](https://www.deepl.com/docs-api/glossaries/get-glossary-entries) and store the current glossary's entries, + - locally modify the glossary entries, + - [delete](https://www.deepl.com/docs-api/glossaries/delete-glossary) the existing glossary, + - [create a new glossary](https://www.deepl.com/docs-api/glossaries/create-glossary) with the same name. +- name: MetaInformation + description: Information about API usage and value ranges +paths: + /translate: + post: + tags: + - TranslateText + summary: Request Translation + operationId: translateText + description: |- + The translate function. + + + The total request body size must not exceed 128 KiB (128 · 1024 bytes). Please split up your text into multiple + calls if it exceeds this limit. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - text + - target_lang + properties: + text: + description: |- + Text to be translated. Only UTF-8-encoded plain text is supported. The parameter may be specified + multiple times and translations are returned in the same order as they are requested. Each of the + parameter values may contain multiple sentences. Up to 50 texts can be sent for translation in one + request. + type: array + maxItems: 50 + items: + type: string + example: Hello, World! + source_lang: + $ref: '#/components/schemas/SourceLanguageText' + target_lang: + $ref: '#/components/schemas/TargetLanguageText' + context: + $ref: '#/components/schemas/Context' + split_sentences: + $ref: '#/components/schemas/SplitSentencesOption' + preserve_formatting: + $ref: '#/components/schemas/PreserveFormattingOption' + formality: + $ref: '#/components/schemas/Formality' + glossary_id: + allOf: + - $ref: '#/components/schemas/GlossaryId' + - description: |- + Specify the glossary to use for the translation. **Important:** This requires the `source_lang` + parameter to be set and the language pair of the glossary has to match the language pair of the + request. + type: string + tag_handling: + $ref: '#/components/schemas/TagHandlingOption' + outline_detection: + $ref: '#/components/schemas/OutlineDetectionOption' + non_splitting_tags: + $ref: '#/components/schemas/NonSplittingTagList' + splitting_tags: + $ref: '#/components/schemas/SplittingTagList' + ignore_tags: + $ref: '#/components/schemas/IgnoreTagList' + application/x-www-form-urlencoded: + examples: + Basic: + summary: Basic Example + value: + text: + - Hello, world! + target_lang: DE + Glossary: + summary: Using a Glossary + value: + text: + - Hello, world! + target_lang: DE + source_lang: EN + glossary_id: '[yourGlossaryId]' + MultipleSentences: + summary: Multiple Sentences + value: + text: + - The table is green. The chair is black. + target_lang: DE + LargeVolumes: + summary: Large Volumes of Text + value: + text: + - This is the first sentence. + - This is the second sentence. + - This is the third sentence. + target_lang: DE + schema: + type: object + required: + - text + - target_lang + properties: + text: + description: Text to be translated. Only UTF-8-encoded plain text + is supported. The parameter may be specified multiple times and + translations are returned in the same order as they are requested. + Each of the parameter values may contain multiple sentences. + type: array + items: + type: string + source_lang: + $ref: '#/components/schemas/SourceLanguageText' + target_lang: + $ref: '#/components/schemas/TargetLanguageText' + context: + $ref: '#/components/schemas/Context' + split_sentences: + $ref: '#/components/schemas/SplitSentencesOption' + preserve_formatting: + $ref: '#/components/schemas/PreserveFormattingOptionStr' + formality: + $ref: '#/components/schemas/Formality' + glossary_id: + allOf: + - $ref: '#/components/schemas/GlossaryId' + description: Specify the glossary to use for the translation. **Important:** + This requires the `source_lang` parameter to be set and the language + pair of the glossary has to match the language pair of the request. + tag_handling: + $ref: '#/components/schemas/TagHandlingOption' + outline_detection: + $ref: '#/components/schemas/OutlineDetectionOptionStr' + non_splitting_tags: + $ref: '#/components/schemas/NonSplittingTagCommaSeparatedList' + splitting_tags: + $ref: '#/components/schemas/SplittingTagCommaSeparatedList' + ignore_tags: + $ref: '#/components/schemas/IgnoreTagCommaSeparatedList' + encoding: + text: + style: form + explode: true + responses: + 200: + description: The translate function returns a JSON representation of the + translations in the order the text parameters have been specified. + content: + application/json: + schema: + type: object + properties: + translations: + type: array + minItems: 1 + items: + type: object + properties: + detected_source_language: + allOf: + - $ref: '#/components/schemas/SourceLanguage' + description: The language detected in the source text. It + reflects the value of the `source_lang` parameter, when + specified. + text: + description: The translated text. + type: string + examples: + Basic: + value: + translations: + - detected_source_language: EN + text: Hallo, Welt! + Glossary: + value: + translations: + - text: Hallo, Welt! + MultipleSentences: + value: + translations: + - detected_source_language: EN + text: Der Tisch ist grün. Der Stuhl ist schwarz. + LargeVolumes: + value: + translations: + - detected_source_language: EN + text: Das ist der erste Satz. + - detected_source_language: EN + text: Das ist der zweite Satz. + - detected_source_language: EN + text: Dies ist der dritte Satz. + 400: + $ref: '#/components/responses/BadRequest' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 414: + $ref: '#/components/responses/URITooLong' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 504: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /document: + post: + tags: + - TranslateDocuments + summary: Upload and Translate a Document + operationId: translateDocument + description: |- + This call uploads a document and queues it for translation. + The call returns once the upload is complete, returning a document ID and key which can be used to + [query the translation status](https://www.deepl.com/docs-api/documents/get-document-status) + and to [download the translated document](https://www.deepl.com/docs-api/documents/download-document) once translation is complete. + + + + Because the request includes a file upload, it must be an HTTP POST request with content type `multipart/form-data`. + + + Please be aware that the uploaded document is automatically removed from the server once the translated document has been downloaded. + You have to upload the document again in order to restart the translation. + + + The maximum upload limit for documents is [available here](https://support.deepl.com/hc/articles/360020582359-Document-formats) + and may vary based on API plan and document type. + + + You may specify the glossary to use for the document translation using the `glossary_id` parameter. + **Important:** This requires the `source_lang` parameter to be set and the language pair of the glossary has to match the language pair of the request. + requestBody: + required: true + content: + multipart/form-data: + examples: + Basic: + summary: Basic Document Translation + value: + target_lang: DE + file: '@document.docx' + Glossary: + summary: Using a Glossary + value: + source_lang: EN + target_lang: DE + file: '@document.docx' + glossary_id: '[yourGlossaryId]' + schema: + type: object + required: + - target_lang + - file + properties: + source_lang: + $ref: '#/components/schemas/SourceLanguage' + target_lang: + $ref: '#/components/schemas/TargetLanguage' + file: + type: string + format: binary + description: |- + The document file to be translated. The file name should be included in this part's content disposition. As an alternative, the filename parameter can be used. The following file types and extensions are supported: + * `docx` - Microsoft Word Document + * `pptx` - Microsoft PowerPoint Document + * `xlsx` - Microsoft Excel Document + * `pdf` - Portable Document Format + * `htm / html` - HTML Document + * `txt` - Plain Text Document + * `xlf / xliff` - XLIFF Document, version 2.1 + filename: + type: string + description: The name of the uploaded file. Can be used as an alternative + to including the file name in the file part's content disposition. + output_format: + type: string + description: |- + File extension of desired format of translated file, for example: `docx`. If unspecified, by default the translated file will be in the same format as the input file. + + Note: Not all combinations of input file and translation file extensions are permitted. See [Document Format Conversions](https://www.deepl.com/docs-api/documents/format-conversions) for the permitted combinations. + formality: + $ref: '#/components/schemas/Formality' + glossary_id: + $ref: '#/components/schemas/GlossaryId' + responses: + 200: + description: The document function returns a JSON object containing the + ID and encryption key assigned to the uploaded document. Once received + by the server, uploaded documents are immediately encrypted using a uniquely + generated encryption key. This key is not persistently stored on the server. + Therefore, it must be stored by the client and sent back to the server + with every subsequent request that refers to this particular document. + content: + application/json: + schema: + type: object + properties: + document_id: + description: A unique ID assigned to the uploaded document and + the translation process. Must be used when referring to this + particular document in subsequent API requests. + type: string + document_key: + description: A unique key that is used to encrypt the uploaded + document as well as the resulting translation on the server + side. Must be provided with every subsequent API request regarding + this particular document. + type: string + example: + document_id: 04DE5AD98A02647D83285A36021911C6 + document_key: 0CB0054F1C132C1625B392EADDA41CB754A742822F6877173029A6C487E7F60A + 400: + $ref: '#/components/responses/BadRequest' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 504: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /document/{document_id}: + post: + tags: + - TranslateDocuments + summary: Check Document Status + description: |- + Retrieve the current status of a document translation process. + If the translation is still in progress, the estimated time remaining is also included in the response. + operationId: getDocumentStatus + parameters: + - $ref: '#/components/parameters/DocumentID' + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DocumentKey' + examples: + basic: + summary: Basic + value: + document_key: 0CB0054F1C132C1625B392EADDA41CB754A742822F6877173029A6C487E7F60A + application/json: + schema: + $ref: '#/components/schemas/DocumentKey' + responses: + 200: + description: The document status request returns a JSON object containing + the document ID that was used in the request as well as string indicating + the current status of the translation process. While the translation is + running, the estimated number of seconds remaining until the process is + done is also included in the response. + content: + application/json: + schema: + type: object + required: + - document_id + - status + properties: + document_id: + description: A unique ID assigned to the uploaded document and + the requested translation process. The same ID that was used + when requesting the translation status. + type: string + status: + description: |- + A short description of the state the document translation process is currently in. Possible values are: + * `queued` - the translation job is waiting in line to be processed + * `translating` - the translation is currently ongoing + * `done` - the translation is done and the translated document is ready for download + * `error` - an irrecoverable error occurred while translating the document + type: string + enum: + - queued + - translating + - done + - error + seconds_remaining: + description: |- + Estimated number of seconds until the translation is done. + This parameter is only included while `status` is `"translating"`. + type: integer + billed_characters: + description: The number of characters billed to your account. + The characters will only be billed after a successful download + request. + type: integer + error_message: + description: |- + A short description of the error, if available. + Note that the content is subject to change. + This parameter may be included if an error occurred during translation. + type: string + examples: + translating: + summary: Translating + value: + document_id: 04DE5AD98A02647D83285A36021911C6 + status: translating + seconds_remaining: 20 + done: + summary: Done + value: + document_id: 04DE5AD98A02647D83285A36021911C6 + status: done + billed_characters: 1337 + queued: + summary: Queued + value: + document_id: 04DE5AD98A02647D83285A36021911C6 + status: queued + error: + summary: Error + value: + document_id: 04DE5AD98A02647D83285A36021911C6 + status: error + message: Source and target language are equal. + 400: + $ref: '#/components/responses/BadRequest' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 504: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /document/{document_id}/result: + post: + tags: + - TranslateDocuments + summary: Download Translated Document + operationId: downloadDocument + description: |- + Once the status of the document translation process is `done`, the result can be downloaded. + + + For privacy reasons the translated document is automatically removed from the server once it was downloaded and cannot be downloaded again. + parameters: + - $ref: '#/components/parameters/DocumentID' + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DocumentKey' + examples: + basic: + summary: Basic + value: + document_key: 0CB0054F1C132C1625B392EADDA41CB754A742822F6877173029A6C487E7F60A + application/json: + schema: + $ref: '#/components/schemas/DocumentKey' + responses: + 200: + description: The document is provided as a download. There is no other data + included in the response besides the document data. The content type used + in the response corresponds to the document type. + content: + application/octet-stream: + schema: + type: string + format: binary + examples: + OK: + summary: OK + description: binary document data + 400: + $ref: '#/components/responses/BadRequest' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound404DocTransDownload' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable503DocTransDownload' + 504: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /glossary-language-pairs: + get: + tags: + - ManageGlossaries + summary: List Language Pairs Supported by Glossaries + description: Retrieve the list of language pairs supported by the glossary feature. + operationId: listGlossaryLanguages + responses: + 200: + description: A JSON object containing the language pairs in its `supported_languages` + property. + content: + application/json: + schema: + type: object + properties: + supported_languages: + type: array + description: The list of supported languages + items: + type: object + required: + - source_lang + - target_lang + properties: + source_lang: + description: The language in which the source texts in the + glossary are specified. + type: string + target_lang: + description: The language in which the target texts in the + glossary are specified. + type: string + example: + supported_languages: + - source_lang: de + target_lang: en + - source_lang: en + target_lang: de + 400: + $ref: '#/components/responses/BadRequestGlossaries' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/ForbiddenGlossaries' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 415: + $ref: '#/components/responses/UnsupportedMediaTypeGlossaries' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /glossaries: + post: + tags: + - ManageGlossaries + summary: Create a Glossary + operationId: createGlossary + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateGlossaryParameters' + examples: + Basic: + value: + name: My Glossary + source_lang: en + target_lang: de + entries: "Hello\tGuten Tag" + entries_format: tsv + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CreateGlossaryParameters' + responses: + 201: + description: The function for creating a glossary returns a JSON object + containing the ID of the newly created glossary and a boolean flag that + indicates if the created glossary can already be used in translate requests. + content: + application/json: + schema: + $ref: '#/components/schemas/Glossary' + 400: + $ref: '#/components/responses/BadRequestGlossaries' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/ForbiddenGlossaries' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 415: + $ref: '#/components/responses/UnsupportedMediaTypeGlossaries' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + get: + tags: + - ManageGlossaries + summary: List all Glossaries + operationId: listGlossaries + description: List all glossaries and their meta-information, but not the glossary + entries. + responses: + 200: + description: JSON object containing a the glossaries. + content: + application/json: + schema: + type: object + properties: + glossaries: + type: array + items: + $ref: '#/components/schemas/Glossary' + example: + glossaries: + - glossary_id: def3a26b-3e84-45b3-84ae-0c0aaf3525f7 + name: My Glossary + ready: true + source_lang: EN + target_lang: DE + creation_time: '2021-08-03T14:16:18.329Z' + entry_count: 1 + 400: + $ref: '#/components/responses/BadRequestGlossaries' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/ForbiddenGlossaries' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 415: + $ref: '#/components/responses/UnsupportedMediaTypeGlossaries' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /glossaries/{glossary_id}: + get: + tags: + - ManageGlossaries + summary: Retrieve Glossary Details + description: Retrieve meta information for a single glossary, omitting the glossary + entries. + operationId: getGlossary + parameters: + - $ref: '#/components/parameters/GlossaryID' + responses: + 200: + description: JSON object containing the glossary meta-information. + content: + application/json: + schema: + $ref: '#/components/schemas/Glossary' + example: + glossary_id: def3a26b-3e84-45b3-84ae-0c0aaf3525f7 + name: My Glossary + ready: true + source_lang: EN + target_lang: DE + creation_time: '2021-08-03T14:16:18.329Z' + entry_count: 1 + 400: + $ref: '#/components/responses/BadRequestGlossaries' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/ForbiddenGlossaries' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 415: + $ref: '#/components/responses/UnsupportedMediaTypeGlossaries' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + delete: + tags: + - ManageGlossaries + summary: Delete a Glossary + description: Deletes the specified glossary. + operationId: deleteGlossary + parameters: + - $ref: '#/components/parameters/GlossaryID' + responses: + 204: + description: Returns no content upon success. + 400: + $ref: '#/components/responses/BadRequestGlossaries' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/ForbiddenGlossaries' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 415: + $ref: '#/components/responses/UnsupportedMediaTypeGlossaries' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /glossaries/{glossary_id}/entries: + get: + tags: + - ManageGlossaries + summary: Retrieve Glossary Entries + operationId: getGlossaryEntries + description: List the entries of a single glossary in the format specified by + the `Accept` header. + parameters: + - $ref: '#/components/parameters/GlossaryID' + - name: Accept + in: header + schema: + type: string + enum: + - text/tab-separated-values + default: text/tab-separated-values + description: The requested format of the returned glossary entries. Currently, + supports only `text/tab-separated-values`. + examples: + tsv: + summary: Tab-separated Values + value: + in: header + Accept: text/tab-separated-values + responses: + 200: + description: The entries in the requested format. + content: + text/tab-separated-values: + example: "Hello!\tGuten Tag!" + 400: + $ref: '#/components/responses/BadRequestGlossaries' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/ForbiddenGlossaries' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 415: + $ref: '#/components/responses/UnsupportedMediaTypeGlossaries' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 503: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /usage: + get: + tags: + - MetaInformation + summary: Check Usage and Limits + description: |- + Retrieve usage information within the current billing period together with the corresponding account limits. Usage is returned for: + - translated characters + - translated documents + - translated documents, team totals (for team accounts only) + + Character usage includes both text and document translations, and is measured by the source text length in Unicode code points, + so for example "A", "Δ", "あ", and "深" are each counted as a single character. + + Document usage only includes document translations, and is measured in individual documents. + + Depending on the user account type, some usage types will be omitted. + Character usage is only included for developer accounts. + Document usage is only included for non-developer accounts, and team-combined document usage is only included for non-developer team accounts. + operationId: getUsage + responses: + 200: + description: The account's usage and limits. + content: + application/json: + schema: + type: object + properties: + character_count: + description: Characters translated so far in the current billing + period. + type: integer + format: int64 + example: 180118 + character_limit: + description: Current maximum number of characters that can be + translated per billing period. If cost control is set, the + cost control limit will be returned in this field. + type: integer + format: int64 + example: 1250000 + 400: + $ref: '#/components/responses/BadRequest' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 504: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] + /languages: + get: + tags: + - MetaInformation + summary: Retrieve Supported Languages + description: |- + Retrieve the list of languages that are currently supported for translation, either as source or target language, respectively. + + As of January 2024, Arabic (AR) is supported as a source and target language for text translation, + but it is not yet supported for document translation. Therefore, Arabic has not yet been added to + the `/languages` endpoint. We will add Arabic to the `/languages` endpoint after document translation + support is added. + operationId: getLanguages + parameters: + - name: type + in: query + description: |- + Sets whether source or target languages should be listed. Possible options are: + * `source` (default): For languages that can be used in the `source_lang` parameter of [translate](https://www.deepl.com/docs-api/translate-text/translate-text) requests. + * `target`: For languages that can be used in the `target_lang` parameter of [translate](https://www.deepl.com/docs-api/translate-text/translate-text) requests. + schema: + type: string + enum: + - source + - target + default: source + examples: + target: + summary: Target Languages + value: + in: query + type: target + responses: + 200: + description: JSON array where each item represents a supported language. + content: + application/json: + schema: + type: array + items: + type: object + required: + - language + - name + properties: + language: + description: The language code of the given language. + type: string + name: + description: Name of the language in English. + type: string + supports_formality: + description: Denotes formality support in case of a target language + listing. + type: boolean + example: + - language: BG + name: Bulgarian + supports_formality: false + - language: CS + name: Czech + supports_formality: false + - language: DA + name: Danish + supports_formality: false + - language: DE + name: German + supports_formality: true + - language: EL + name: Greek + supports_formality: false + - language: EN-GB + name: English (British) + supports_formality: false + - language: EN-US + name: English (American) + supports_formality: false + - language: ES + name: Spanish + supports_formality: true + - language: ET + name: Estonian + supports_formality: false + - language: FI + name: Finnish + supports_formality: false + - language: FR + name: French + supports_formality: true + - language: HU + name: Hungarian + supports_formality: false + - language: ID + name: Indonesian + supports_formality: false + - language: IT + name: Italian + supports_formality: true + - language: JA + name: Japanese + supports_formality: true + - language: KO + name: Korean + supports_formality: false + - language: LT + name: Lithuanian + supports_formality: false + - language: LV + name: Latvian + supports_formality: false + - language: NB + name: Norwegian (Bokmål) + supports_formality: false + - language: NL + name: Dutch + supports_formality: true + - language: PL + name: Polish + supports_formality: true + - language: PT-BR + name: Portuguese (Brazilian) + supports_formality: true + - language: PT-PT + name: Portuguese (European) + supports_formality: true + - language: RO + name: Romanian + supports_formality: false + - language: RU + name: Russian + supports_formality: true + - language: SK + name: Slovak + supports_formality: false + - language: SL + name: Slovenian + supports_formality: false + - language: SV + name: Swedish + supports_formality: false + - language: TR + name: Turkish + supports_formality: false + - language: UK + name: Ukrainian + supports_formality: false + - language: ZH + name: Chinese (simplified) + supports_formality: false + 400: + $ref: '#/components/responses/BadRequest' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 413: + $ref: '#/components/responses/PayloadTooLarge' + 429: + $ref: '#/components/responses/TooManyRequests' + 456: + $ref: '#/components/responses/QuotaExceeded' + 500: + $ref: '#/components/responses/InternalServerError' + 504: + $ref: '#/components/responses/ServiceUnavailable' + 529: + $ref: '#/components/responses/TooManyRequests' + security: + - auth_header: [] +components: + parameters: + DocumentID: + name: document_id + description: The document ID that was sent to the client when the document was + uploaded to the API. + in: path + required: true + schema: + type: string + example: 04DE5AD98A02647D83285A36021911C6 + GlossaryID: + name: glossary_id + description: A unique ID assigned to the glossary. + in: path + required: true + schema: + type: string + SourceLanguage: + name: source_lang + in: query + schema: + $ref: '#/components/schemas/SourceLanguage' + TargetLanguage: + name: target_lang + in: query + required: true + schema: + $ref: '#/components/schemas/TargetLanguage' + responses: + BadRequest: + description: Bad request. Please check error message and your parameters. + BadRequestGlossaries: + description: Bad request. Please check error message and your parameters. + content: + application/json: + schema: + type: object + properties: + message: + description: Generic description of the error. + type: string + detail: + description: More specific description of the error. + type: string + example: + message: Invalid glossary entries provided + detail: Key with the index 1 (starting at position 13) duplicates key + with the index 0 (starting at position 0) + Unauthorized: + description: Authorization failed. Please supply a valid `DeepL-Auth-Key` via + the `Authorization` header. + Forbidden: + description: Authorization failed. Please supply a valid `DeepL-Auth-Key` via + the `Authorization` header. + ForbiddenGlossaries: + description: Forbidden. The access to the requested resource is denied, because + of insufficient access rights. + NotFound: + description: The requested resource could not be found. + NotFound404DocTransDownload: + description: Trying to download a document using a non-existing document ID + or the wrong document key will result in a 404 error. As stated above, documents + can only be downloaded once before they are deleted from the server and their + document ID is invalidated. + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentTranslationError' + examples: + NotFound: + summary: Not Found + value: + message: Document not found + PayloadTooLarge: + description: The request size exceeds the limit. + URITooLong: + description: The request URL is too long. You can avoid this error by using + a POST request instead of a GET request, and sending the parameters in the + HTTP body. + UnsupportedMediaTypeGlossaries: + description: The requested entries format specified in the `Accept` header is + not supported. + TooManyRequests: + description: Too many requests. Please wait and resend your request. + QuotaExceeded: + description: Quota exceeded. The character limit has been reached. + InternalServerError: + description: Internal error. + ServiceUnavailable: + description: Resource currently unavailable. Try again later. + ServiceUnavailable503DocTransDownload: + description: |- + A 503 result will be returned if the user tries to download a translated document that is currently being processed and is not yet ready for download. + Please make sure to check that the document status is 'done' before trying to send a download request. + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentTranslationError' + examples: + AlreadyDownloaded: + summary: Already Downloaded + value: + message: Document already downloaded + securitySchemes: + auth_header: + type: apiKey + description: Authentication with `Authorization` header and `DeepL-Auth-Key` + authentication scheme + name: Authorization + in: header + schemas: + Context: + description: |- + The `context` parameter makes it possible to include additional context that can influence a translation but is not translated itself. + This additional context can potentially improve translation quality when translating short, low-context source texts such + as product names on an e-commerce website, article headlines on a news website, or UI elements. + + + For example... + - When translating a product name, you might pass the product description as context. + - When translating a news article headline, you might pass the first few sentences or a summary of the article as context. + + + For best results, we recommend sending a few complete sentences of context. There is no size limit for the `context` parameter itself, but + the request body size limit of 128 KiB still applies to all text translation requests. + + + If you send a request with multiple `text` parameters, the `context` parameter will be applied to each one. + + + The `context` parameter is an **alpha feature** as defined in section 3.1.5 of our [terms and conditions](https://www.deepl.com/en/pro-license). + This means it could be changed or deprecated by DeepL at any point and without advance notice. While the feature is still labeled as "alpha", + context will not be counted toward billing (i.e. there is no additional cost for sending context). This is subject to change if the "alpha" + label is removed and the feature becomes generally available. If we decide to deprecate the `context` parameter, requests that include it will not + break; the context will simply be ignored. + + + For these reasons, **the `context` parameter is not intended to be used in production** as long as the alpha label still applies. + + + We're eager to hear how the `context` parameter is working for you and how we can improve the feature! You can share your feedback + by emailing api-feedback@deepl.com. + type: string + CreateGlossaryParameters: + type: object + required: + - name + - source_lang + - target_lang + - entries + - entries_format + properties: + name: + description: Name to be associated with the glossary. + type: string + example: My Glossary + source_lang: + $ref: '#/components/schemas/GlossarySourceLanguage' + target_lang: + $ref: '#/components/schemas/GlossaryTargetLanguage' + entries: + description: The entries of the glossary. The entries have to be specified + in the format provided by the `entries_format` parameter. + type: string + example: "Hello\tGuten Tag" + entries_format: + description: |- + The format in which the glossary entries are provided. Formats currently available: + - `tsv` (default) - tab-separated values + - `csv` - comma-separated values + + See [Supported Glossary Formats](https://www.deepl.com/docs-api/glossaries/formats) for details about each format. + type: string + enum: + - tsv + - csv + example: tsv + default: tsv + DocumentTranslationError: + type: object + properties: + message: + type: string + description: detailed error message + DocumentKey: + type: object + required: + - document_key + properties: + document_key: + description: The document encryption key that was sent to the client when + the document was uploaded to the API. + type: string + example: 0CB0054F1C132C1625B392EADDA41CB754A742822F6877173029A6C487E7F60A + Formality: + description: |- + Sets whether the translated text should lean towards formal or informal language. + This feature currently only works for target languages + `DE` (German), + `FR` (French), + `IT` (Italian), + `ES` (Spanish), + `NL` (Dutch), + `PL` (Polish), + `PT-BR` and `PT-PT` (Portuguese), + `JA` (Japanese), + and `RU` (Russian). + Learn more about the plain/polite feature for Japanese [here](https://support.deepl.com/hc/en-us/articles/6306700061852-About-the-plain-polite-feature-in-Japanese). + Setting this parameter with a target language that does not support formality will fail, + unless one of the `prefer_...` options are used. + Possible options are: + * `default` (default) + * `more` - for a more formal language + * `less` - for a more informal language + * `prefer_more` - for a more formal language if available, otherwise fallback to default formality + * `prefer_less` - for a more informal language if available, otherwise fallback to default formality + type: string + enum: + - default + - more + - less + - prefer_more + - prefer_less + default: default + GlossaryId: + type: string + description: A unique ID assigned to a glossary. + example: def3a26b-3e84-45b3-84ae-0c0aaf3525f7 + Glossary: + type: object + properties: + glossary_id: + $ref: '#/components/schemas/GlossaryId' + name: + description: Name associated with the glossary. + type: string + ready: + description: |- + Indicates if the newly created glossary can already be used in `translate` requests. + If the created glossary is not yet ready, you have to wait and check the `ready` status + of the glossary before using it in a `translate` request. + type: boolean + source_lang: + $ref: '#/components/schemas/GlossarySourceLanguage' + target_lang: + $ref: '#/components/schemas/GlossaryTargetLanguage' + creation_time: + description: 'The creation time of the glossary in the ISO 8601-1:2019 format + (e.g.: `2021-08-03T14:16:18.329Z`).' + type: string + format: date-time + entry_count: + description: The number of entries in the glossary. + type: integer + example: + glossary_id: def3a26b-3e84-45b3-84ae-0c0aaf3525f7 + ready: true + name: My Glossary + source_lang: en + target_lang: de + creation_time: '2021-08-03T14:16:18.329Z' + entry_count: 1 + GlossarySourceLanguage: + type: string + description: The language in which the source texts in the glossary are specified. + enum: + - de + - en + - es + - fr + - it + - ja + - ko + - nl + - pl + - pt + - ru + - zh + example: en + GlossaryTargetLanguage: + type: string + description: The language in which the target texts in the glossary are specified. + enum: + - de + - en + - es + - fr + - it + - ja + - ko + - nl + - pl + - pt + - ru + - zh + example: de + OutlineDetectionOption: + description: |- + The automatic detection of the XML structure won't yield best results in all XML files. You can disable this automatic mechanism altogether by setting the `outline_detection` parameter to `false` and selecting the tags that should be considered structure tags. This will split sentences using the `splitting_tags` parameter. + + + In the example below, we achieve the same results as the automatic engine by disabling automatic detection with `outline_detection=0` and setting the parameters manually to `tag_handling=xml`, `split_sentences=nonewlines`, and `splitting_tags=par,title`. + * Example request: + ``` + + + A document's title + + + This is the first sentence. Followed by a second one. + This is the third sentence. + + + ``` + * Example response: + ``` + + + Der Titel eines Dokuments + + + Das ist der erste Satz. Gefolgt von einem zweiten. + Dies ist der dritte Satz. + + + ``` + While this approach is slightly more complicated, it allows for greater control over the structure of the translation output. + type: boolean + default: true + OutlineDetectionOptionStr: + description: |- + The automatic detection of the XML structure won't yield best results in all XML files. You can disable this automatic mechanism altogether by setting the `outline_detection` parameter to `0` and selecting the tags that should be considered structure tags. This will split sentences using the `splitting_tags` parameter. + + + In the example below, we achieve the same results as the automatic engine by disabling automatic detection with `outline_detection=0` and setting the parameters manually to `tag_handling=xml`, `split_sentences=nonewlines`, and `splitting_tags=par,title`. + * Example request: + ``` + + + A document's title + + + This is the first sentence. Followed by a second one. + This is the third sentence. + + + ``` + * Example response: + ``` + + + Der Titel eines Dokuments + + + Das ist der erste Satz. Gefolgt von einem zweiten. + Dies ist der dritte Satz. + + + ``` + While this approach is slightly more complicated, it allows for greater control over the structure of the translation output. + type: string + enum: + - '0' + PreserveFormattingOption: + description: |- + Sets whether the translation engine should respect the original formatting, even if it would usually correct some aspects. + + The formatting aspects affected by this setting include: + * Punctuation at the beginning and end of the sentence + * Upper/lower case at the beginning of the sentence + type: boolean + default: false + PreserveFormattingOptionStr: + description: |- + Sets whether the translation engine should respect the original formatting, even if it would usually correct some aspects. Possible values are: + * `0` (default) + * `1` + + The formatting aspects affected by this setting include: + * Punctuation at the beginning and end of the sentence + * Upper/lower case at the beginning of the sentence + type: string + enum: + - '0' + - '1' + default: '0' + SplitSentencesOption: + description: |- + Sets whether the translation engine should first split the input into sentences. For text translations where + `tag_handling` is not set to `html`, the default value is `1`, meaning the engine splits on punctuation and on newlines. + + For text translations where `tag_handling=html`, the default value is `nonewlines`, meaning the engine splits on punctuation only, ignoring newlines. + + The use of `nonewlines` as the default value for text translations where `tag_handling=html` is new behavior that was implemented in November 2022, + when HTML handling was moved out of beta. + + Possible values are: + + * `0` - no splitting at all, whole input is treated as one sentence + * `1` (default when `tag_handling` is not set to `html`) - splits on punctuation and on newlines + * `nonewlines` (default when `tag_handling=html`) - splits on punctuation only, ignoring newlines + + For applications that send one sentence per text parameter, we recommend setting `split_sentences` to `0`, in order to prevent the engine from splitting the sentence unintentionally. + + + Please note that newlines will split sentences when `split_sentences=1`. We recommend cleaning files so they don't contain breaking sentences or setting the parameter `split_sentences` to `nonewlines`. + type: string + enum: + - '0' + - '1' + - nonewlines + default: '1' + SourceLanguage: + type: string + description: |- + Language of the text to be translated. Options currently available: + * `BG` - Bulgarian + * `CS` - Czech + * `DA` - Danish + * `DE` - German + * `EL` - Greek + * `EN` - English + * `ES` - Spanish + * `ET` - Estonian + * `FI` - Finnish + * `FR` - French + * `HU` - Hungarian + * `ID` - Indonesian + * `IT` - Italian + * `JA` - Japanese + * `KO` - Korean + * `LT` - Lithuanian + * `LV` - Latvian + * `NB` - Norwegian (Bokmål) + * `NL` - Dutch + * `PL` - Polish + * `PT` - Portuguese (all Portuguese varieties mixed) + * `RO` - Romanian + * `RU` - Russian + * `SK` - Slovak + * `SL` - Slovenian + * `SV` - Swedish + * `TR` - Turkish + * `UK` - Ukrainian + * `ZH` - Chinese + + If this parameter is omitted, the API will attempt to detect the language of the text and translate it. + enum: + - BG + - CS + - DA + - DE + - EL + - EN + - ES + - ET + - FI + - FR + - HU + - ID + - IT + - JA + - KO + - LT + - LV + - NB + - NL + - PL + - PT + - RO + - RU + - SK + - SL + - SV + - TR + - UK + - ZH + example: EN + SourceLanguageText: + type: string + description: |- + Language of the text to be translated. Options currently available: + * `AR` - Arabic [1] + * `BG` - Bulgarian + * `CS` - Czech + * `DA` - Danish + * `DE` - German + * `EL` - Greek + * `EN` - English + * `ES` - Spanish + * `ET` - Estonian + * `FI` - Finnish + * `FR` - French + * `HU` - Hungarian + * `ID` - Indonesian + * `IT` - Italian + * `JA` - Japanese + * `KO` - Korean + * `LT` - Lithuanian + * `LV` - Latvian + * `NB` - Norwegian (Bokmål) + * `NL` - Dutch + * `PL` - Polish + * `PT` - Portuguese (all Portuguese varieties mixed) + * `RO` - Romanian + * `RU` - Russian + * `SK` - Slovak + * `SL` - Slovenian + * `SV` - Swedish + * `TR` - Turkish + * `UK` - Ukrainian + * `ZH` - Chinese + + If this parameter is omitted, the API will attempt to detect the language of the text and translate it. + + [1] Please note that Arabic has not yet been added to the `/languages` endpoint because it does not + yet support document translation; only text translation is supported for Arabic at this time. When + document translation support is added for Arabic, we will a) remove this note and b) add Arabic to + the `/languages` endpoint. + enum: + - AR + - BG + - CS + - DA + - DE + - EL + - EN + - ES + - ET + - FI + - FR + - HU + - ID + - IT + - JA + - KO + - LT + - LV + - NB + - NL + - PL + - PT + - RO + - RU + - SK + - SL + - SV + - TR + - UK + - ZH + example: EN + TagHandlingOption: + description: |- + Sets which kind of tags should be handled. Options currently available: + * `xml`: Enable XML tag handling; see [XML Handling](https://www.deepl.com/docs-api/xml). + * `html`: Enable HTML tag handling; see [HTML Handling](https://www.deepl.com/docs-api/html). + type: string + enum: + - xml + - html + NonSplittingTagCommaSeparatedList: + allOf: + - $ref: '#/components/schemas/TagCommaSeparatedList' + description: |- + Comma-separated list of XML tags which never split sentences. + + + For some XML files, finding tags with textual content and splitting sentences using those tags won't yield the best results. The following example shows the engine splitting sentences on `par` tags and proceeding to translate the parts separately, resulting in an incorrect translation: + * Example request: + ``` + The firm said it had been conducting an internal investigation. + ``` + * Example response: + ``` + Die Firma sagte, es sei eine gute Idee gewesen. Durchführung einer internen Untersuchung. + ``` + + + As this can lead to bad translations, this type of structure should either be avoided, or the `non_splitting_tags` parameter should be set. The following example shows the same call, with the parameter set to `par`: + * Example request: + ``` + The firm said it had been conducting an internal investigation. + ``` + * Example response: + ``` + Die Firma sagte, dass sie eine interne Untersuchung durchgeführt habe. + ``` + + + This time, the sentence is translated as a whole. The XML tags are now considered markup and copied into the translated sentence. As the translation of the words "had been" has moved to another position in the German sentence, the two par tags are duplicated (which is expected here). + NonSplittingTagList: + allOf: + - $ref: '#/components/schemas/TagList' + description: |- + List of XML tags which never split sentences. + + + For some XML files, finding tags with textual content and splitting sentences using those tags won't yield the best results. The following example shows the engine splitting sentences on `par` tags and proceeding to translate the parts separately, resulting in an incorrect translation: + * Example request: + ``` + The firm said it had been conducting an internal investigation. + ``` + * Example response: + ``` + Die Firma sagte, es sei eine gute Idee gewesen. Durchführung einer internen Untersuchung. + ``` + + + As this can lead to bad translations, this type of structure should either be avoided, or the `non_splitting_tags` parameter should be set. The following example shows the same call, with the parameter set to `par`: + * Example request: + ``` + The firm said it had been conducting an internal investigation. + ``` + * Example response: + ``` + Die Firma sagte, dass sie eine interne Untersuchung durchgeführt habe. + ``` + + + This time, the sentence is translated as a whole. The XML tags are now considered markup and copied into the translated sentence. As the translation of the words "had been" has moved to another position in the German sentence, the two par tags are duplicated (which is expected here). + SplittingTagCommaSeparatedList: + allOf: + - $ref: '#/components/schemas/TagCommaSeparatedList' + description: |- + Comma-separated list of XML tags which always cause splits. + + + See the example in the `outline_detection` parameter's description. + SplittingTagList: + allOf: + - $ref: '#/components/schemas/TagList' + description: |- + List of XML tags which always cause splits. + + + See the example in the `outline_detection` parameter's description. + IgnoreTagCommaSeparatedList: + allOf: + - $ref: '#/components/schemas/TagCommaSeparatedList' + description: |- + Comma-separated list of XML tags that indicate text not to be translated. + + + Use this parameter to ensure that elements in the original text are not altered in the translation (e.g., trademarks, product names) and insert tags into your original text. In the following example, the `ignore_tags` parameter is set to `keep`: + * Example request: + ``` + Please open the page Settings to configure your system. + ``` + * Example response: + ``` + Bitte öffnen Sie die Seite Settings um Ihr System zu konfigurieren. + ``` + IgnoreTagList: + allOf: + - $ref: '#/components/schemas/TagList' + description: |- + List of XML tags that indicate text not to be translated. + + + Use this parameter to ensure that elements in the original text are not altered in the translation (e.g., trademarks, product names) and insert tags into your original text. In the following example, the `ignore_tags` parameter is set to `keep`: + * Example request: + ``` + Please open the page Settings to configure your system. + ``` + * Example response: + ``` + Bitte öffnen Sie die Seite Settings um Ihr System zu konfigurieren. + ``` + TagCommaSeparatedList: + description: Comma-separated list of XML or HTML tags. + type: string + example: a,p,span + TagList: + description: List of XML or HTML tags. + type: array + items: + type: string + example: + - a + - p + - span + TargetLanguage: + type: string + description: |- + The language into which the text should be translated. Options currently available: + * `BG` - Bulgarian + * `CS` - Czech + * `DA` - Danish + * `DE` - German + * `EL` - Greek + * `EN` - English (unspecified variant for backward compatibility; please select `EN-GB` or `EN-US` instead) + * `EN-GB` - English (British) + * `EN-US` - English (American) + * `ES` - Spanish + * `ET` - Estonian + * `FI` - Finnish + * `FR` - French + * `HU` - Hungarian + * `ID` - Indonesian + * `IT` - Italian + * `JA` - Japanese + * `KO` - Korean + * `LT` - Lithuanian + * `LV` - Latvian + * `NB` - Norwegian (Bokmål) + * `NL` - Dutch + * `PL` - Polish + * `PT` - Portuguese (unspecified variant for backward compatibility; please select `PT-BR` or `PT-PT` instead) + * `PT-BR` - Portuguese (Brazilian) + * `PT-PT` - Portuguese (all Portuguese varieties excluding Brazilian Portuguese) + * `RO` - Romanian + * `RU` - Russian + * `SK` - Slovak + * `SL` - Slovenian + * `SV` - Swedish + * `TR` - Turkish + * `UK` - Ukrainian + * `ZH` - Chinese (simplified) + enum: + - BG + - CS + - DA + - DE + - EL + - EN-GB + - EN-US + - ES + - ET + - FI + - FR + - HU + - ID + - IT + - JA + - KO + - LT + - LV + - NB + - NL + - PL + - PT-BR + - PT-PT + - RO + - RU + - SK + - SL + - SV + - TR + - UK + - ZH + example: DE + TargetLanguageText: + type: string + description: |- + The language into which the text should be translated. Options currently available: + * `AR` - Arabic [1] + * `BG` - Bulgarian + * `CS` - Czech + * `DA` - Danish + * `DE` - German + * `EL` - Greek + * `EN` - English (unspecified variant for backward compatibility; please select `EN-GB` or `EN-US` instead) + * `EN-GB` - English (British) + * `EN-US` - English (American) + * `ES` - Spanish + * `ET` - Estonian + * `FI` - Finnish + * `FR` - French + * `HU` - Hungarian + * `ID` - Indonesian + * `IT` - Italian + * `JA` - Japanese + * `KO` - Korean + * `LT` - Lithuanian + * `LV` - Latvian + * `NB` - Norwegian (Bokmål) + * `NL` - Dutch + * `PL` - Polish + * `PT` - Portuguese (unspecified variant for backward compatibility; please select `PT-BR` or `PT-PT` instead) + * `PT-BR` - Portuguese (Brazilian) + * `PT-PT` - Portuguese (all Portuguese varieties excluding Brazilian Portuguese) + * `RO` - Romanian + * `RU` - Russian + * `SK` - Slovak + * `SL` - Slovenian + * `SV` - Swedish + * `TR` - Turkish + * `UK` - Ukrainian + * `ZH` - Chinese (simplified) + + [1] Please note that Arabic has not yet been added to the `/languages` endpoint because + it does not yet support document translation; only text translation is supported for Arabic + at this time. When document translation support is added for Arabic, we will a) remove this + note and b) add Arabic to the `/languages` endpoint. + enum: + - AR + - BG + - CS + - DA + - DE + - EL + - EN-GB + - EN-US + - ES + - ET + - FI + - FR + - HU + - ID + - IT + - JA + - KO + - LT + - LV + - NB + - NL + - PL + - PT-BR + - PT-PT + - RO + - RU + - SK + - SL + - SV + - TR + - UK + - ZH + example: DE diff --git a/libninja/tests/spec/recurly.yaml b/test_specs/recurly.yaml similarity index 100% rename from libninja/tests/spec/recurly.yaml rename to test_specs/recurly.yaml