diff --git a/schemars/src/gen.rs b/schemars/src/gen.rs index 20d83bc..87ec2be 100644 --- a/schemars/src/gen.rs +++ b/schemars/src/gen.rs @@ -121,6 +121,22 @@ impl SchemaGenerator { &self.settings } + // TODO document/rename + #[doc(hidden)] + pub fn objectify(&self, schema: SchemaObject) -> SchemaObject { + if schema.is_ref() { + SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + all_of: Some(vec![schema.into()]), + ..Default::default() + })), + ..Default::default() + } + } else { + schema + } + } + /// Returns a `Schema` that matches everything, such as the empty schema `{}`. /// /// The exact value returned depends on this generator's [`BoolSchemas`](struct.SchemaSettings.html#structfield.bool_schemas) setting. @@ -196,7 +212,7 @@ impl SchemaGenerator { /// [`definitions`](../schema/struct.Metadata.html#structfield.definitions) pub fn root_schema_for(&mut self) -> RootSchema { let mut schema: SchemaObject = T::json_schema(self).into(); - schema.metadata().title = Some(T::schema_name()); + schema.metadata().title.get_or_insert_with(T::schema_name); RootSchema { meta_schema: self.settings.meta_schema.clone(), definitions: self.definitions.clone(), @@ -210,7 +226,7 @@ impl SchemaGenerator { /// include them in the returned `SchemaObject`'s [`definitions`](../schema/struct.Metadata.html#structfield.definitions) pub fn into_root_schema_for(mut self) -> RootSchema { let mut schema: SchemaObject = T::json_schema(&mut self).into(); - schema.metadata().title = Some(T::schema_name()); + schema.metadata().title.get_or_insert_with(T::schema_name); RootSchema { meta_schema: self.settings.meta_schema, definitions: self.definitions, diff --git a/schemars/tests/docs.rs b/schemars/tests/docs.rs new file mode 100644 index 0000000..6877fd5 --- /dev/null +++ b/schemars/tests/docs.rs @@ -0,0 +1,53 @@ +mod util; +use schemars::JsonSchema; +use util::*; + +#[derive(Debug, JsonSchema)] +/** + * + * # This is the struct's title + * + * This is the struct's description. + * + */ +pub struct MyStruct { + /// # An integer + pub my_int: i32, + pub my_undocumented_bool: bool, + /// A unit struct instance + pub my_unit: MyUnitStruct, +} + +/// # A Unit +#[derive(Debug, JsonSchema)] +pub struct MyUnitStruct; + +#[doc = " # This is the enum's title "] +#[doc = " This is..."] +#[derive(Debug, JsonSchema)] +#[doc = "...the enum's description. "] +pub enum MyEnum { + UndocumentedUnit, + /// This comment is not included in the generated schema :( + DocumentedUnit, + /// ## Complex variant + /// This is a struct-like variant. + Complex { + /// ### A nullable string + /// + /// This field is a nullable string. + /// + /// This is another line! + my_nullable_string: Option, + }, +} + +#[test] +fn doc_comments_struct() -> TestResult { + test_default_generated_schema::("doc_comments_struct") +} + +#[test] +fn doc_comments_enum() -> TestResult { + test_default_generated_schema::("doc_comments_enum") +} diff --git a/schemars_derive/src/doc_attrs.rs b/schemars_derive/src/doc_attrs.rs new file mode 100644 index 0000000..70ef2ef --- /dev/null +++ b/schemars_derive/src/doc_attrs.rs @@ -0,0 +1,63 @@ +use syn::{Attribute, Lit::Str, Meta::NameValue, MetaNameValue}; + +pub fn get_title_and_desc_from_docs(attrs: &[Attribute]) -> (Option, Option) { + let docs = match get_docs(attrs) { + None => return (None, None), + Some(docs) => docs, + }; + + if docs.starts_with('#') { + let mut split = docs.splitn(2, '\n'); + let title = split + .next() + .unwrap() + .trim_start_matches('#') + .trim() + .to_owned(); + let maybe_desc = split.next().map(|s| s.trim().to_owned()); + (none_if_empty(title), maybe_desc) + } else { + (None, none_if_empty(docs)) + } +} + +fn none_if_empty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn get_docs(attrs: &[Attribute]) -> Option { + let doc_attrs = attrs + .iter() + .filter_map(|attr| { + if !attr.path.is_ident("doc") { + return None; + } + + let meta = attr.parse_meta().ok()?; + if let NameValue(MetaNameValue { lit: Str(s), .. }) = meta { + return Some(s.value()); + } + + None + }) + .collect::>(); + + if doc_attrs.is_empty() { + return None; + } + + let mut docs = doc_attrs + .iter() + .flat_map(|a| a.split('\n')) + .map(str::trim) + .skip_while(|s| *s == "") + .collect::>() + .join("\n"); + + docs.truncate(docs.trim_end().len()); + Some(docs) +} diff --git a/schemars_derive/src/lib.rs b/schemars_derive/src/lib.rs index 51cbde3..901a974 100644 --- a/schemars_derive/src/lib.rs +++ b/schemars_derive/src/lib.rs @@ -4,6 +4,8 @@ extern crate quote; extern crate syn; extern crate proc_macro; +mod doc_attrs; +mod metadata; mod preprocess; use proc_macro2::TokenStream; @@ -13,7 +15,7 @@ use serde_derive_internals::attr::{self, Default as SerdeDefault, TagType}; use serde_derive_internals::{Ctxt, Derive}; use syn::spanned::Spanned; -#[proc_macro_derive(JsonSchema, attributes(schemars, serde))] +#[proc_macro_derive(JsonSchema, attributes(schemars, serde, doc))] pub fn derive_json_schema(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let mut input = parse_macro_input!(input as syn::DeriveInput); @@ -29,13 +31,14 @@ pub fn derive_json_schema(input: proc_macro::TokenStream) -> proc_macro::TokenSt } let cont = cont.expect("from_ast set no errors on Ctxt, so should have returned Some"); - let schema = match cont.data { + let schema_expr = match cont.data { Data::Struct(Style::Unit, _) => schema_for_unit_struct(), Data::Struct(Style::Newtype, ref fields) => schema_for_newtype_struct(&fields[0]), Data::Struct(Style::Tuple, ref fields) => schema_for_tuple_struct(fields), Data::Struct(Style::Struct, ref fields) => schema_for_struct(fields, &cont.attrs), Data::Enum(ref variants) => schema_for_enum(variants, &cont.attrs), }; + let schema_expr = metadata::set_metadata_on_schema_from_docs(schema_expr, &cont.original.attrs); let type_name = cont.ident; let type_params: Vec<_> = cont.generics.type_params().map(|ty| &ty.ident).collect(); @@ -72,7 +75,7 @@ pub fn derive_json_schema(input: proc_macro::TokenStream) -> proc_macro::TokenSt } fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - #schema + #schema_expr } }; }; @@ -277,24 +280,22 @@ fn schema_for_struct(fields: &[Field], cattrs: &attr::Container) -> TokenStream if !container_has_default && !has_default(field.attrs.default()) { required.push(name.clone()); } - let ty = get_json_schema_type(field); - if field.attrs.skip_deserializing() { - quote_spanned! {field.original.span()=> - let mut schema: schemars::schema::SchemaObject = gen.subschema_for::<#ty>().into(); - schema.metadata().read_only = true; - props.insert(#name.to_owned(), schema.into()); - } - } else if field.attrs.skip_serializing() { - quote_spanned! {field.original.span()=> - let mut schema: schemars::schema::SchemaObject = gen.subschema_for::<#ty>().into(); - schema.metadata().write_only = true; - props.insert(#name.to_owned(), schema.into()); - } - } else { - quote_spanned! {field.original.span()=> - props.insert(#name.to_owned(), gen.subschema_for::<#ty>()); - } + let ty = get_json_schema_type(field); + let span = field.original.span(); + let schema_expr = quote_spanned! {span=> + gen.subschema_for::<#ty>() + }; + + let metadata = metadata::SchemaMetadata { + read_only: field.attrs.skip_deserializing(), + write_only: field.attrs.skip_serializing(), + ..metadata::get_metadata_from_docs(&field.original.attrs) + }; + let schema_expr = metadata::set_metadata_on_schema(schema_expr, &metadata); + + quote_spanned! {span=> + props.insert(#name.to_owned(), #schema_expr); } }); diff --git a/schemars_derive/src/metadata.rs b/schemars_derive/src/metadata.rs new file mode 100644 index 0000000..1cf7b62 --- /dev/null +++ b/schemars_derive/src/metadata.rs @@ -0,0 +1,67 @@ +use crate::doc_attrs; +use proc_macro2::TokenStream; +use syn::Attribute; + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SchemaMetadata { + pub title: Option, + pub description: Option, + pub read_only: bool, + pub write_only: bool, +} + +pub fn set_metadata_on_schema_from_docs( + schema_expr: TokenStream, + attrs: &[Attribute], +) -> TokenStream { + let metadata = get_metadata_from_docs(attrs); + set_metadata_on_schema(schema_expr, &metadata) +} + +pub fn get_metadata_from_docs(attrs: &[Attribute]) -> SchemaMetadata { + let (title, description) = doc_attrs::get_title_and_desc_from_docs(attrs); + SchemaMetadata { + title, + description, + ..Default::default() + } +} + +pub fn set_metadata_on_schema(schema_expr: TokenStream, metadata: &SchemaMetadata) -> TokenStream { + let mut setters = Vec::::new(); + + if let Some(title) = &metadata.title { + setters.push(quote! { + metadata.title = Some(#title.to_owned()); + }) + } + if let Some(description) = &metadata.description { + setters.push(quote! { + metadata.description = Some(#description.to_owned()); + }) + } + if metadata.read_only { + setters.push(quote! { + metadata.read_only = true; + }) + } + if metadata.write_only { + setters.push(quote! { + metadata.write_only = true; + }) + } + + if setters.is_empty() { + return schema_expr; + } + + quote! { + { + let schema = #schema_expr.into(); + let mut schema_obj = gen.objectify(schema); + let mut metadata = schema_obj.metadata(); + #(#setters)* + schemars::schema::Schema::Object(schema_obj) + } + } +} diff --git a/schemars_derive/src/preprocess.rs b/schemars_derive/src/preprocess.rs index 4839d53..4b59c5a 100644 --- a/schemars_derive/src/preprocess.rs +++ b/schemars_derive/src/preprocess.rs @@ -42,23 +42,28 @@ fn process_serde_field_attrs<'a>(ctxt: &Ctxt, fields: impl Iterator) { - let mut serde_meta: Vec = attrs - .iter() - .filter(|a| a.path.is_ident("serde")) - .flat_map(|attr| get_meta_items(&ctxt, attr)) - .flatten() - .collect(); + let mut schemars_attrs = Vec::::new(); + let mut serde_attrs = Vec::::new(); + let mut misc_attrs = Vec::::new(); - attrs.retain(|a| a.path.is_ident("schemars")); + for attr in attrs.drain(..) { + if attr.path.is_ident("schemars") { + schemars_attrs.push(attr) + } else if attr.path.is_ident("serde") { + serde_attrs.push(attr) + } else { + misc_attrs.push(attr) + } + } - for attr in attrs.iter_mut() { + for attr in schemars_attrs.iter_mut() { let schemars_ident = attr.path.segments.pop().unwrap().into_value().ident; attr.path .segments .push(Ident::new("serde", schemars_ident.span()).into()); } - let mut schemars_meta_names: BTreeSet = attrs + let mut schemars_meta_names: BTreeSet = schemars_attrs .iter() .flat_map(|attr| get_meta_items(&ctxt, attr)) .flatten() @@ -69,25 +74,32 @@ fn process_attrs(ctxt: &Ctxt, attrs: &mut Vec) { schemars_meta_names.insert("deserialize_with".to_string()); } - serde_meta.retain(|m| { - get_meta_ident(&ctxt, m) - .map(|i| !schemars_meta_names.contains(&i.to_string())) - .unwrap_or(false) - }); + let mut serde_meta = serde_attrs + .iter() + .flat_map(|attr| get_meta_items(&ctxt, attr)) + .flatten() + .filter(|m| { + get_meta_ident(&ctxt, m) + .map(|i| !schemars_meta_names.contains(&i)) + .unwrap_or(false) + }) + .peekable(); - if serde_meta.is_empty() { - return; + *attrs = schemars_attrs; + + if serde_meta.peek().is_some() { + let new_serde_attr = quote! { + #[serde(#(#serde_meta),*)] + }; + + let parser = Attribute::parse_outer; + match parser.parse2(new_serde_attr) { + Ok(ref mut parsed) => attrs.append(parsed), + Err(e) => ctxt.error_spanned_by(to_tokens(attrs), e), + } } - let new_serde_attr = quote! { - #[serde(#(#serde_meta),*)] - }; - - let parser = Attribute::parse_outer; - match parser.parse2(new_serde_attr) { - Ok(ref mut parsed) => attrs.append(parsed), - Err(e) => ctxt.error_spanned_by(to_tokens(attrs), e), - } + attrs.extend(misc_attrs) } fn to_tokens(attrs: &[Attribute]) -> impl ToTokens { @@ -142,6 +154,7 @@ mod tests { #[schemars(container2 = "overridden", container4)] #[misc] struct MyStruct { + /// blah blah blah #[serde(field, field2)] field1: i32, #[serde(field, field2, serialize_with = "se", deserialize_with = "de")] @@ -154,8 +167,10 @@ mod tests { let expected: DeriveInput = parse_quote! { #[serde(container2 = "overridden", container4)] #[serde(container, container3(foo, bar))] + #[misc] struct MyStruct { #[serde(field, field2)] + #[doc = r" blah blah blah"] field1: i32, #[serde(field = "overridden", with = "with")] #[serde(field2)]