diff --git a/book/actix-plugin.md b/book/actix-plugin.md index d5cc47700..038a42bba 100644 --- a/book/actix-plugin.md +++ b/book/actix-plugin.md @@ -185,7 +185,8 @@ Similarly, if we were to use other extractors like `web::Query`, `web::Form` containing those abstractions. So currently, the plugin silently ignores these types, which results in an empty value in your hosted specification. #### Missing features diff --git a/core/src/v2/schema.rs b/core/src/v2/schema.rs index 3ae1471f8..a869f82e6 100644 --- a/core/src/v2/schema.rs +++ b/core/src/v2/schema.rs @@ -54,6 +54,16 @@ pub trait Schema: Sized { /// - `serde_json::Value` works for both JSON and YAML. fn enum_variants(&self) -> Option<&[serde_json::Value]>; + /// More complex enum variants, where each has it's own schema. + fn any_of(&self) -> Option<&Vec>>; + + /// A constant value for this schema. It's `serde_json::Value` + /// because: + /// + /// - Constants are allowed to have any value. + /// - `serde_json::Value` works for both JSON and YAML. + fn const_value(&self) -> Option<&serde_json::Value>; + /// Returns whether this definition "is" or "has" `Any` type. fn contains_any(&self) -> bool { _schema_contains_any(self, vec![]) diff --git a/macros/src/actix.rs b/macros/src/actix.rs index 099d1fc08..6db04e0bf 100644 --- a/macros/src/actix.rs +++ b/macros/src/actix.rs @@ -735,7 +735,7 @@ pub fn emit_v2_definition(input: TokenStream) -> TokenStream { } } } - Data::Enum(ref e) => handle_enum(e, &props, &mut props_gen), + Data::Enum(ref e) => handle_enum(e, &item_ast.attrs, &props, &mut props_gen), Data::Union(ref u) => emit_error!( u.union_token.span().unwrap(), "unions are unsupported for deriving schema" @@ -1255,41 +1255,187 @@ fn handle_field_struct( } /// Generates code for an enum (if supported). -fn handle_enum(e: &DataEnum, serde: &SerdeProps, props_gen: &mut proc_macro2::TokenStream) { - props_gen.extend(quote!( - schema.data_type = Some(DataType::String); - )); +fn handle_enum( + e: &DataEnum, + attrs: &[Attribute], + serde: &SerdeProps, + props_gen: &mut proc_macro2::TokenStream, +) { + // set whether constants are inline strings + let simple_constants = serde.enum_tag_type == SerdeEnumTagType::External + || serde.enum_tag_type == SerdeEnumTagType::Untagged; + + // check if all variants are simple constants and can use `enum` + // otherwise we'll make use of `any_of` + let only_simple_constants = simple_constants + && !e + .variants + .iter() + .any(|variant| variant.fields != Fields::Unit); + if only_simple_constants { + // we'll use the enum syntax later on and can declare this to be of type string + props_gen.extend(quote!( + schema.data_type = Some(DataType::String); + )); + } - for var in &e.variants { - let mut name = var.ident.to_string(); - match &var.fields { - Fields::Unit => (), - Fields::Named(ref f) => { - emit_warning!( - f.span().unwrap(), - "skipping enum variant with named fields in schema." - ); - continue; - } - Fields::Unnamed(ref f) => { - emit_warning!(f.span().unwrap(), "skipping tuple enum variant in schema."); - continue; - } - } + let doc = extract_documentation(attrs); + let doc = doc.trim(); + if !doc.is_empty() { + props_gen.extend(quote!( + schema.description = Some(#doc.into()); + )); + } + for var in &e.variants { if SerdeSkip::exists(&var.attrs) { continue; } + let mut name = var.ident.to_string(); if let Some(renamed) = SerdeRename::from_field_attrs(&var.attrs) { name = renamed; } else if let Some(prop) = serde.rename { name = prop.rename(&name); } - props_gen.extend(quote!( - schema.enum_.push(serde_json::json!(#name)); - )); + if only_simple_constants { + props_gen.extend(quote!( + schema.enum_.push(serde_json::json!(#name)); + )); + } else { + // this will aggregate the construction of the variant schema + let mut inner_gen = quote!(); + // this indicate if the schema is effectively empty + let mut inner_gen_empty = false; + + let docs = extract_documentation(&var.attrs); + let docs = docs.trim(); + + match &var.fields { + Fields::Unit => { + // unit constants may be simple constant types + if simple_constants { + props_gen.extend(quote!( + schema.any_of.push(DefaultSchemaRaw { + const_: Some(serde_json::json!(#name)), + description: if #docs.is_empty() { None } else { Some(#docs.into()) }, + ..Default::default() + }.into()); + )); + continue; + } + + // this is required so there's something to add tags to + inner_gen = quote!( + let mut schema = DefaultSchemaRaw { + data_type: Some(DataType::Object), + description: if #docs.is_empty() { None } else { Some(#docs.into()) }, + ..Default::default() + }; + ); + inner_gen_empty = true; + } + Fields::Named(ref f) => { + inner_gen.extend(quote!( + let mut schema = DefaultSchemaRaw { + data_type: Some(DataType::Object), + description: if #docs.is_empty() { None } else { Some(#docs.into()) }, + ..Default::default() + }; + )); + handle_field_struct(f, &[], serde, &mut inner_gen); + } + Fields::Unnamed(ref f) => { + // Fix this once handle_unnamed_field_struct does actually create arrays + emit_warning!(f.span().unwrap(), "skipping tuple enum variant in schema."); + continue; + } + } + + match serde.enum_tag_type { + SerdeEnumTagType::External => { + props_gen.extend(quote!( + schema.any_of.push({ + let mut schema = DefaultSchemaRaw { + data_type: Some(DataType::Object), + ..Default::default() + }; + schema.properties.insert(#name.into(), { + #inner_gen + schema + }.into()); + schema.required.insert(#name.into()); + + schema + }.into()); + )); + } + SerdeEnumTagType::Internal(ref tag) => { + props_gen.extend(quote!( + schema.any_of.push({ + #inner_gen + schema.properties.insert(#tag.into(), DefaultSchemaRaw { + const_: Some(serde_json::json!(#name)), + ..Default::default() + }.into()); + schema.required.insert(#tag.into()); + schema + }.into()); + )); + } + SerdeEnumTagType::Adjacent(ref tag, ref content_tag) => { + // if the variant schema is empty, we don't need the content tag + if inner_gen_empty { + props_gen.extend(quote!( + schema.any_of.push({ + let mut schema = DefaultSchemaRaw { + data_type: Some(DataType::Object), + ..Default::default() + }; + schema.properties.insert(#tag.into(), DefaultSchemaRaw { + const_: Some(serde_json::json!(#name)), + description: if #docs.is_empty() { None } else { Some(#docs.into()) }, + ..Default::default() + }.into()); + schema.required.insert(#tag.into()); + schema + }.into()); + )); + } else { + props_gen.extend(quote!( + schema.any_of.push({ + let mut schema = DefaultSchemaRaw { + data_type: Some(DataType::Object), + ..Default::default() + }; + schema.properties.insert(#tag.into(), DefaultSchemaRaw { + const_: Some(serde_json::json!(#name)), + description: if #docs.is_empty() { None } else { Some(#docs.into()) }, + ..Default::default() + }.into()); + schema.properties.insert(#content_tag.into(), { + #inner_gen + schema + }.into()); + schema.required.insert(#tag.into()); + schema.required.insert(#content_tag.into()); + + schema + }.into()); + )); + } + } + SerdeEnumTagType::Untagged => { + props_gen.extend(quote!( + schema.any_of.push({ + #inner_gen + schema + }.into()); + )); + } + } + } } } @@ -1437,6 +1583,21 @@ impl SerdeSkip { #[derive(Clone, Debug, Default)] struct SerdeProps { rename: Option, + enum_tag_type: SerdeEnumTagType, +} + +#[derive(Clone, Debug, PartialEq)] +enum SerdeEnumTagType { + External, + Internal(String), + Adjacent(String, String), + Untagged, +} + +impl Default for SerdeEnumTagType { + fn default() -> Self { + SerdeEnumTagType::External + } } impl SerdeProps { @@ -1444,6 +1605,8 @@ impl SerdeProps { /// the applicable properties. fn from_item_attrs(item_attrs: &[Attribute]) -> Self { let mut props = Self::default(); + let mut enum_tag: Option = None; + let mut enum_content_tag: Option = None; for meta in item_attrs.iter().filter_map(|a| a.parse_meta().ok()) { let inner_meta = match meta { Meta::List(ref l) @@ -1459,22 +1622,48 @@ impl SerdeProps { }; for meta in inner_meta { - let global_rename = match meta { - NestedMeta::Meta(Meta::NameValue(ref v)) - if v.path - .segments + match meta { + NestedMeta::Meta(Meta::NameValue(ref v)) => { + if let Some(segment) = v.path.segments.last() { + match segment.ident.to_string().as_str() { + "rename_all" => { + if let Lit::Str(ref s) = &v.lit { + props.rename = s.value().parse().ok(); + } + } + "tag" => { + if let Lit::Str(ref s) = &v.lit { + enum_tag = Some(s.value()); + } + } + "content" => { + if let Lit::Str(ref s) = &v.lit { + enum_content_tag = Some(s.value()); + } + } + _ => {} + } + } + } + NestedMeta::Meta(Meta::Path(syn::Path { segments, .. })) => { + if segments .last() - .map(|p| p.ident == "rename_all") - .unwrap_or(false) => - { - &v.lit + .map(|p| p.ident == "untagged") + .unwrap_or(false) + { + props.enum_tag_type = SerdeEnumTagType::Untagged; + } } _ => continue, }; + } + } - if let Lit::Str(ref s) = global_rename { - props.rename = s.value().parse().ok(); - } + if let Some(tag) = enum_tag { + if let Some(content_tag) = enum_content_tag { + props.enum_tag_type = SerdeEnumTagType::Adjacent(tag, content_tag); + } else { + props.enum_tag_type = SerdeEnumTagType::Internal(tag); } } diff --git a/macros/src/core.rs b/macros/src/core.rs index ea9f864ab..3116e3480 100644 --- a/macros/src/core.rs +++ b/macros/src/core.rs @@ -182,6 +182,20 @@ pub fn emit_v2_schema_struct(input: TokenStream) -> TokenStream { Some(&self.enum_) } } + + #[inline] + fn any_of(&self) -> Option<&Vec>> { + if self.any_of.is_empty() { + None + } else { + Some(&self.any_of) + } + } + + #[inline] + fn const_value(&self) -> Option<&serde_json::Value> { + self.const_.as_ref() + } } }); @@ -308,6 +322,18 @@ fn schema_fields(name: &Ident, is_ref: bool) -> proc_macro2::TokenStream { pub enum_: Vec, )); + gen.extend(quote!( + #[serde(default, rename = "anyOf", skip_serializing_if = "Vec::is_empty")] + pub any_of: Vec< + )); + add_self(&mut gen); + gen.extend(quote!(>,)); + + gen.extend(quote!( + #[serde(default, rename="const", skip_serializing_if = "Option::is_none")] + pub const_: Option, + )); + gen.extend(quote!( #[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")] pub extra_props: Option impl Future, Error>> { + fut_ok(web::Json(PetClassExternal::Dog)) + } + + #[get("/internal")] + #[api_v2_operation] + fn internal() -> impl Future, Error>> { + fut_ok(web::Json(PetClassInternal::Dog)) + } + + #[get("/adjacent")] + #[api_v2_operation] + fn adjacent() -> impl Future, Error>> { + fut_ok(web::Json(PetClassAdjacent::Dog)) + } + + #[get("/untagged")] + #[api_v2_operation] + fn untagged() -> impl Future, Error>> { + fut_ok(web::Json(PetClassUntagged::Dog { woof: true })) + } + + run_and_check_app( + || { + App::new() + .wrap_api() + .service(external) + .service(internal) + .service(adjacent) + .service(untagged) + .with_raw_json_spec(|app, spec| { + app.route( + "/api/spec", + web::get().to(move || actix_web::HttpResponse::Ok().json(&spec)), + ) + }) + .build() + }, + |addr| { + let resp = CLIENT + .get(&format!("http://{}/api/spec", addr)) + .send() + .expect("request failed?"); + + check_json( + resp, + json!({ + "definitions": { + "PetClassExternal": { + "description": "Pets are awesome!", + "anyOf": [ + { + "description": "It's a dog", + "const": "dog" + }, + { + "type": "object", + "properties": { + "cat": { + "type": "object", + "description": "It's a cat", + "properties": { + "meow": { + "type": "boolean" + } + }, + "required": ["meow"] + } + }, + "required": ["cat"] + }, + { + "const": "other" + }, + ] + }, + "PetClassInternal": { + "description": "Pets are awesome!", + "anyOf": [ + { + "type": "object", + "description": "It's a dog", + "properties": { + "type": { + "const": "dog" + }, + }, + "required": ["type"] + }, + { + "type": "object", + "description": "It's a cat", + "properties": { + "type": { + "const": "cat" + }, + "meow": { + "type": "boolean" + } + }, + "required": ["meow", "type"] + }, + { + "type": "object", + "properties": { + "type": { + "const": "other" + }, + }, + "required": ["type"] + } + ] + }, + "PetClassAdjacent": { + "description": "Pets are awesome!", + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "description": "It's a dog", + "const": "dog" + }, + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "description": "It's a cat", + "const": "cat" + }, + "c": { + "description": "It's a cat", + "type": "object", + "properties": { + "meow": { + "type": "boolean" + } + }, + "required": ["meow"] + } + }, + "required": ["c", "type"] + }, + { + "type": "object", + "properties": { + "type": { + "const": "other" + }, + }, + "required": ["type"] + } + ] + }, + "PetClassUntagged": { + "description": "Pets are awesome!", + "anyOf": [ + { + "description": "It's a dog", + "type": "object", + "properties": { + "woof": { + "type": "boolean" + } + }, + "required": ["woof"] + }, + { + "description": "It's a cat", + "type": "object", + "properties": { + "meow": { + "type": "boolean" + } + }, + "required": ["meow"] + } + ] + } + }, + "info": { + "title":"", + "version":"" + }, + "paths": { + "/external": { + "get": { + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/PetClassExternal" + } + } + }, + } + }, + "/internal": { + "get": { + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/PetClassInternal" + } + } + }, + } + }, + "/adjacent": { + "get": { + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/PetClassAdjacent" + } + } + }, + } + }, + "/untagged": { + "get": { + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/PetClassUntagged" + } + } + }, + } + } + }, + "swagger": "2.0" + }), + ); + }, + ); +} + mod module_path_in_definition_name { pub mod foo { pub mod bar {