Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement actix schema generator for tagged enums #389

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion book/actix-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ Similarly, if we were to use other extractors like `web::Query<T>`, `web::Form<T

#### Known limitations

- **Enums:** OpenAPI (v2) itself supports using simple enums (i.e., with unit variants), but Rust and serde has support for variants with fields and tuples. I still haven't looked deep enough either to say whether this can/cannot be done in OpenAPI or find an elegant way to represent this in OpenAPI.
- **Tuples:** Tuples with more than one field are currently not supported.
- **Enums:** Enums with fields will be serialized [according to serde](https://serde.rs/enum-representations.html). Enum variants with unnamed (tuple) fields are currently not supported though.
- **Functions returning abstractions:** The plugin has no way to obtain any useful information from functions returning abstractions such as `HttpResponse`, `impl Responder` or containers such as `Result<T, E>` containing those abstractions. So currently, the plugin silently ignores these types, which results in an empty value in your hosted specification.

#### Missing features
Expand Down
10 changes: 10 additions & 0 deletions core/src/v2/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Resolvable<Self>>>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than just any_of I wonder if we could have a schema kind that would allow for all the other variants (oneOf, etc)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

example:

pub enum SchemaKind {
    Type(crate::v2::models::DataType),
    OneOf {
        #[serde(rename = "oneOf")]
        one_of: Vec<crate::v2::models::Resolvable<Self>>,
    },
    AllOf {
        #[serde(rename = "allOf")]
        all_of: Vec<crate::v2::models::Resolvable<Self>>,
    },
    AnyOf {
        #[serde(rename = "anyOf")]
        any_of: Vec<crate::v2::models::Resolvable<Self>>,
    },
}


/// 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![])
Expand Down
259 changes: 224 additions & 35 deletions macros/src/actix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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());
));
}
}
}
}
}

Expand Down Expand Up @@ -1437,13 +1583,30 @@ impl SerdeSkip {
#[derive(Clone, Debug, Default)]
struct SerdeProps {
rename: Option<SerdeRename>,
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 {
/// Traverses the serde attributes in the given item attributes and returns
/// the applicable properties.
fn from_item_attrs(item_attrs: &[Attribute]) -> Self {
let mut props = Self::default();
let mut enum_tag: Option<String> = None;
let mut enum_content_tag: Option<String> = None;
for meta in item_attrs.iter().filter_map(|a| a.parse_meta().ok()) {
let inner_meta = match meta {
Meta::List(ref l)
Expand All @@ -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);
}
}

Expand Down
26 changes: 26 additions & 0 deletions macros/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ pub fn emit_v2_schema_struct(input: TokenStream) -> TokenStream {
Some(&self.enum_)
}
}

#[inline]
fn any_of(&self) -> Option<&Vec<paperclip::v2::models::Resolvable<Self>>> {
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()
}
}
});

Expand Down Expand Up @@ -308,6 +322,18 @@ fn schema_fields(name: &Ident, is_ref: bool) -> proc_macro2::TokenStream {
pub enum_: Vec<serde_json::Value>,
));

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<serde_json::Value>,
));

gen.extend(quote!(
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
pub extra_props: Option<paperclip::v2::models::Either<bool,
Expand Down
Loading