Allow arbitrary expressions in doc/title/description attributes (#327)
This commit is contained in:
parent
5547e77bcd
commit
df06fc5f66
17 changed files with 206 additions and 159 deletions
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
@ -5,18 +5,15 @@ on: [push, pull_request, workflow_dispatch]
|
|||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# work-around https://github.com/rust-lang/cargo/issues/10303
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: ${{ matrix.rust == '1.60.0' }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust:
|
||||
- 1.60.0
|
||||
- 1.65.0
|
||||
- stable
|
||||
- beta
|
||||
- nightly
|
||||
include:
|
||||
- rust: 1.60.0
|
||||
- rust: 1.65.0
|
||||
test_features: ""
|
||||
allow_failure: false
|
||||
- rust: stable
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
[](https://github.com/GREsau/schemars/actions)
|
||||
[](https://crates.io/crates/schemars)
|
||||
[](https://docs.rs/schemars/1.0.0--latest)
|
||||
[](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html)
|
||||
[](https://blog.rust-lang.org/2022/11/03/Rust-1.65.0.html)
|
||||
|
||||
Generate JSON Schema documents from Rust code
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ license = "MIT"
|
|||
readme = "README.md"
|
||||
keywords = ["rust", "json-schema", "serde"]
|
||||
categories = ["encoding", "no-std"]
|
||||
rust-version = "1.60"
|
||||
rust-version = "1.65"
|
||||
|
||||
[dependencies]
|
||||
schemars_derive = { version = "=1.0.0-alpha.10", optional = true, path = "../schemars_derive" }
|
||||
|
|
|
@ -4,6 +4,10 @@ use crate::{JsonSchema, Schema, SchemaGenerator};
|
|||
use serde::Serialize;
|
||||
use serde_json::{json, map::Entry, Map, Value};
|
||||
|
||||
mod rustdoc;
|
||||
|
||||
pub use rustdoc::get_title_and_description;
|
||||
|
||||
// Helper for generating schemas for flattened `Option` fields.
|
||||
pub fn json_schema_for_flatten<T: ?Sized + JsonSchema>(
|
||||
generator: &mut SchemaGenerator,
|
||||
|
@ -161,6 +165,17 @@ pub fn insert_metadata_property(schema: &mut Schema, key: &str, value: impl Into
|
|||
schema.ensure_object().insert(key.to_owned(), value.into());
|
||||
}
|
||||
|
||||
pub fn insert_metadata_property_if_nonempty(
|
||||
schema: &mut Schema,
|
||||
key: &str,
|
||||
value: impl Into<String>,
|
||||
) {
|
||||
let value: String = value.into();
|
||||
if !value.is_empty() {
|
||||
insert_metadata_property(schema, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_validation_property(
|
||||
schema: &mut Schema,
|
||||
required_type: &str,
|
94
schemars/src/_private/rustdoc.rs
Normal file
94
schemars/src/_private/rustdoc.rs
Normal file
|
@ -0,0 +1,94 @@
|
|||
pub const fn get_title_and_description(doc: &str) -> (&str, &str) {
|
||||
let doc_bytes = trim_ascii(doc.as_bytes());
|
||||
|
||||
if !doc_bytes.is_empty() && doc_bytes[0] == b'#' {
|
||||
let title_end_index = match strchr(doc_bytes, b'\n') {
|
||||
Some(i) => i,
|
||||
None => doc_bytes.len(),
|
||||
};
|
||||
|
||||
let title = trim_ascii(trim_start(subslice(doc_bytes, 0, title_end_index), b'#'));
|
||||
let description = trim_ascii(subslice(doc_bytes, title_end_index, doc_bytes.len()));
|
||||
|
||||
(to_utf8(title), to_utf8(description))
|
||||
} else {
|
||||
("", to_utf8(doc_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
const fn strchr(bytes: &[u8], chr: u8) -> Option<usize> {
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
while i < len {
|
||||
if bytes[i] == chr {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const fn subslice(mut bytes: &[u8], mut start: usize, end: usize) -> &[u8] {
|
||||
let mut trim_end_count = bytes.len() - end;
|
||||
if trim_end_count > 0 {
|
||||
while let [rest @ .., _last] = bytes {
|
||||
bytes = rest;
|
||||
|
||||
trim_end_count -= 1;
|
||||
if trim_end_count == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if start > 0 {
|
||||
while let [_first, rest @ ..] = bytes {
|
||||
bytes = rest;
|
||||
|
||||
start -= 1;
|
||||
if start == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
const fn to_utf8(bytes: &[u8]) -> &str {
|
||||
match core::str::from_utf8(bytes) {
|
||||
Ok(x) => x,
|
||||
Err(_) => panic!("Invalid UTF-8"),
|
||||
}
|
||||
}
|
||||
|
||||
const fn trim_start(mut bytes: &[u8], chr: u8) -> &[u8] {
|
||||
while let [first, rest @ ..] = bytes {
|
||||
if *first == chr {
|
||||
bytes = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
const fn trim_ascii(mut bytes: &[u8]) -> &[u8] {
|
||||
while let [first, rest @ ..] = bytes {
|
||||
if first.is_ascii_whitespace() {
|
||||
bytes = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while let [rest @ .., last] = bytes {
|
||||
if last.is_ascii_whitespace() {
|
||||
bytes = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
|
@ -458,9 +458,8 @@ impl SchemaGenerator {
|
|||
}
|
||||
|
||||
let pointer = self.definitions_path_stripped();
|
||||
let target = match json_pointer_mut(schema_object, pointer, true) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
let Some(target) = json_pointer_mut(schema_object, pointer, true) else {
|
||||
return;
|
||||
};
|
||||
|
||||
target.append(&mut definitions);
|
||||
|
|
|
@ -315,16 +315,13 @@ impl Transform for RemoveRefSiblings {
|
|||
fn transform(&mut self, schema: &mut Schema) {
|
||||
transform_subschemas(self, schema);
|
||||
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
if obj.len() > 1 {
|
||||
if let Some(ref_value) = obj.remove("$ref") {
|
||||
if let Value::Array(all_of) =
|
||||
obj.entry("allOf").or_insert(Value::Array(Vec::new()))
|
||||
{
|
||||
all_of.push(json!({
|
||||
"$ref": ref_value
|
||||
}));
|
||||
}
|
||||
if let Some(obj) = schema.as_object_mut().filter(|o| o.len() > 1) {
|
||||
if let Some(ref_value) = obj.remove("$ref") {
|
||||
if let Value::Array(all_of) = obj.entry("allOf").or_insert(Value::Array(Vec::new()))
|
||||
{
|
||||
all_of.push(json!({
|
||||
"$ref": ref_value
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -403,15 +400,14 @@ impl Transform for ReplaceUnevaluatedProperties {
|
|||
fn transform(&mut self, schema: &mut Schema) {
|
||||
transform_subschemas(self, schema);
|
||||
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
if let Some(up) = obj.remove("unevaluatedProperties") {
|
||||
obj.insert("additionalProperties".to_owned(), up);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
let Some(obj) = schema.as_object_mut() else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(up) = obj.remove("unevaluatedProperties") else {
|
||||
return;
|
||||
};
|
||||
|
||||
obj.insert("additionalProperties".to_owned(), up);
|
||||
|
||||
let mut gather_property_names = GatherPropertyNames::default();
|
||||
gather_property_names.transform(schema);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
mod util;
|
||||
use schemars::{generate::SchemaSettings, JsonSchema};
|
||||
use schemars::JsonSchema;
|
||||
use util::*;
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
@ -15,6 +15,9 @@ struct MyStruct {
|
|||
my_undocumented_bool: bool,
|
||||
/// A unit struct instance
|
||||
my_unit: MyUnitStruct,
|
||||
#[doc = concat!("# Documented ", "bool")]
|
||||
#[doc = concat!("This bool is documented")]
|
||||
my_documented_bool: bool,
|
||||
}
|
||||
|
||||
/// # A Unit
|
||||
|
@ -57,12 +60,6 @@ fn doc_comments_struct() -> TestResult {
|
|||
test_default_generated_schema::<MyStruct>("doc_comments_struct")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_comments_struct_ref_siblings() -> TestResult {
|
||||
let settings = SchemaSettings::draft2019_09();
|
||||
test_generated_schema::<MyStruct>("doc_comments_struct_ref_siblings", settings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_comments_enum() -> TestResult {
|
||||
test_default_generated_schema::<MyEnum>("doc_comments_enum")
|
||||
|
@ -81,6 +78,8 @@ struct OverrideDocs {
|
|||
/// Also overridden
|
||||
#[schemars(title = "", description = "")]
|
||||
my_undocumented_bool: bool,
|
||||
#[schemars(title = concat!("Documented ", "bool"), description = "Capitalized".to_uppercase())]
|
||||
my_documented_bool: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
@ -12,10 +12,16 @@
|
|||
},
|
||||
"my_undocumented_bool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"my_documented_bool": {
|
||||
"title": "Documented bool",
|
||||
"description": "CAPITALIZED",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"my_int",
|
||||
"my_undocumented_bool"
|
||||
"my_undocumented_bool",
|
||||
"my_documented_bool"
|
||||
]
|
||||
}
|
|
@ -15,12 +15,18 @@
|
|||
"my_unit": {
|
||||
"description": "A unit struct instance",
|
||||
"$ref": "#/$defs/MyUnitStruct"
|
||||
},
|
||||
"my_documented_bool": {
|
||||
"title": "Documented bool",
|
||||
"description": "This bool is documented",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"my_int",
|
||||
"my_undocumented_bool",
|
||||
"my_unit"
|
||||
"my_unit",
|
||||
"my_documented_bool"
|
||||
],
|
||||
"$defs": {
|
||||
"MyUnitStruct": {
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2019-09/schema",
|
||||
"title": "This is the struct's title",
|
||||
"description": "This is the struct's description.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"my_int": {
|
||||
"title": "An integer",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"my_undocumented_bool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"my_unit": {
|
||||
"description": "A unit struct instance",
|
||||
"$ref": "#/$defs/MyUnitStruct"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"my_int",
|
||||
"my_undocumented_bool",
|
||||
"my_unit"
|
||||
],
|
||||
"$defs": {
|
||||
"MyUnitStruct": {
|
||||
"title": "A Unit",
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ license = "MIT"
|
|||
readme = "README.md"
|
||||
keywords = ["rust", "json-schema", "serde"]
|
||||
categories = ["encoding", "no-std"]
|
||||
rust-version = "1.60"
|
||||
rust-version = "1.65"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
|
|
@ -26,7 +26,6 @@ impl<'a> FromSerde for Container<'a> {
|
|||
serde_attrs: serde.attrs,
|
||||
data: Data::from_serde(errors, serde.data)?,
|
||||
generics: serde.generics.clone(),
|
||||
original: serde.original,
|
||||
// FIXME this allows with/schema_with attribute on containers
|
||||
attrs: Attrs::new(&serde.original.attrs, errors),
|
||||
})
|
||||
|
|
|
@ -10,7 +10,6 @@ pub struct Container<'a> {
|
|||
pub serde_attrs: serde_derive_internals::attr::Container,
|
||||
pub data: Data<'a>,
|
||||
pub generics: syn::Generics,
|
||||
pub original: &'a syn::DeriveInput,
|
||||
pub attrs: Attrs,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,54 +1,25 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::ToTokens;
|
||||
use syn::Attribute;
|
||||
|
||||
pub fn get_title_and_desc_from_doc(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
|
||||
let doc = match get_doc(attrs) {
|
||||
None => return (None, None),
|
||||
Some(doc) => doc,
|
||||
};
|
||||
pub fn get_doc(attrs: &[Attribute]) -> Option<syn::Expr> {
|
||||
let joiner = quote! {, "\n",};
|
||||
let mut macro_args: TokenStream = TokenStream::new();
|
||||
|
||||
if doc.starts_with('#') {
|
||||
let mut split = doc.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, Some(doc))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_doc(attrs: &[Attribute]) -> Option<String> {
|
||||
let lines = attrs
|
||||
for nv in attrs
|
||||
.iter()
|
||||
.filter_map(|attr| {
|
||||
if !attr.path().is_ident("doc") {
|
||||
return None;
|
||||
}
|
||||
.filter(|a| a.path().is_ident("doc"))
|
||||
.filter_map(|a| a.meta.require_name_value().ok())
|
||||
{
|
||||
if !macro_args.is_empty() {
|
||||
macro_args.extend(joiner.clone());
|
||||
}
|
||||
macro_args.extend(nv.value.to_token_stream());
|
||||
}
|
||||
|
||||
let meta = attr.meta.require_name_value().ok()?;
|
||||
if let syn::Expr::Lit(syn::ExprLit {
|
||||
lit: syn::Lit::Str(lit_str),
|
||||
..
|
||||
}) = &meta.value
|
||||
{
|
||||
return Some(lit_str.value());
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
none_if_empty(lines.join("\n").trim().to_owned())
|
||||
}
|
||||
|
||||
fn none_if_empty(s: String) -> Option<String> {
|
||||
if s.is_empty() {
|
||||
if macro_args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
Some(parse_quote!(::core::concat!(#macro_args)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ use proc_macro2::{Group, Span, TokenStream, TokenTree};
|
|||
use quote::ToTokens;
|
||||
use serde_derive_internals::Ctxt;
|
||||
use syn::parse::{self, Parse};
|
||||
use syn::{LitStr, Meta, MetaNameValue};
|
||||
use syn::{Expr, LitStr, Meta, MetaNameValue};
|
||||
|
||||
// FIXME using the same struct for containers+variants+fields means that
|
||||
// with/schema_with are accepted (but ignored) on containers, and
|
||||
|
@ -19,15 +19,16 @@ use syn::{LitStr, Meta, MetaNameValue};
|
|||
#[derive(Debug, Default)]
|
||||
pub struct Attrs {
|
||||
pub with: Option<WithAttr>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub title: Option<Expr>,
|
||||
pub description: Option<Expr>,
|
||||
pub doc: Option<Expr>,
|
||||
pub deprecated: bool,
|
||||
pub examples: Vec<syn::Path>,
|
||||
pub repr: Option<syn::Type>,
|
||||
pub crate_name: Option<syn::Path>,
|
||||
pub is_renamed: bool,
|
||||
pub extensions: Vec<(String, TokenStream)>,
|
||||
pub transforms: Vec<syn::Expr>,
|
||||
pub transforms: Vec<Expr>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -48,26 +49,15 @@ impl Attrs {
|
|||
.find(|a| a.path().is_ident("repr"))
|
||||
.and_then(|a| a.parse_args().ok());
|
||||
|
||||
let (doc_title, doc_description) = doc::get_title_and_desc_from_doc(attrs);
|
||||
result.title = result.title.or(doc_title);
|
||||
result.description = result.description.or(doc_description);
|
||||
|
||||
result.doc = doc::get_doc(attrs);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn as_metadata(&self) -> SchemaMetadata<'_> {
|
||||
#[allow(clippy::ptr_arg)]
|
||||
fn none_if_empty(s: &String) -> Option<&str> {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
SchemaMetadata {
|
||||
title: self.title.as_ref().and_then(none_if_empty),
|
||||
description: self.description.as_ref().and_then(none_if_empty),
|
||||
doc: self.doc.as_ref(),
|
||||
title: self.title.as_ref(),
|
||||
description: self.description.as_ref(),
|
||||
deprecated: self.deprecated,
|
||||
examples: &self.examples,
|
||||
extensions: &self.extensions,
|
||||
|
@ -128,25 +118,15 @@ impl Attrs {
|
|||
}
|
||||
}
|
||||
|
||||
Meta::NameValue(m) if m.path.is_ident("title") => {
|
||||
if let Ok(title) = expr_as_lit_str(errors, attr_type, "title", &m.value) {
|
||||
match self.title {
|
||||
Some(_) => duplicate_error(m),
|
||||
None => self.title = Some(title.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Meta::NameValue(m) if m.path.is_ident("title") => match self.title {
|
||||
Some(_) => duplicate_error(m),
|
||||
None => self.title = Some(m.value.clone()),
|
||||
},
|
||||
|
||||
Meta::NameValue(m) if m.path.is_ident("description") => {
|
||||
if let Ok(description) =
|
||||
expr_as_lit_str(errors, attr_type, "description", &m.value)
|
||||
{
|
||||
match self.description {
|
||||
Some(_) => duplicate_error(m),
|
||||
None => self.description = Some(description.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Meta::NameValue(m) if m.path.is_ident("description") => match self.description {
|
||||
Some(_) => duplicate_error(m),
|
||||
None => self.description = Some(m.value.clone()),
|
||||
},
|
||||
|
||||
Meta::NameValue(m) if m.path.is_ident("example") => {
|
||||
if let Ok(fun) = parse_lit_into_path(errors, attr_type, "example", &m.value) {
|
||||
|
@ -239,6 +219,7 @@ impl Attrs {
|
|||
with: None,
|
||||
title: None,
|
||||
description: None,
|
||||
doc: None,
|
||||
deprecated: false,
|
||||
examples,
|
||||
repr: None,
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
use proc_macro2::TokenStream;
|
||||
use syn::spanned::Spanned;
|
||||
use syn::{spanned::Spanned, Expr};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SchemaMetadata<'a> {
|
||||
pub title: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub title: Option<&'a Expr>,
|
||||
pub description: Option<&'a Expr>,
|
||||
pub doc: Option<&'a Expr>,
|
||||
pub deprecated: bool,
|
||||
pub read_only: bool,
|
||||
pub write_only: bool,
|
||||
pub examples: &'a [syn::Path],
|
||||
pub default: Option<TokenStream>,
|
||||
pub extensions: &'a [(String, TokenStream)],
|
||||
pub transforms: &'a [syn::Expr],
|
||||
pub transforms: &'a [Expr],
|
||||
}
|
||||
|
||||
impl<'a> SchemaMetadata<'a> {
|
||||
|
@ -35,14 +36,29 @@ impl<'a> SchemaMetadata<'a> {
|
|||
fn make_setters(&self) -> Vec<TokenStream> {
|
||||
let mut setters = Vec::<TokenStream>::new();
|
||||
|
||||
if let Some(doc) = &self.doc {
|
||||
if self.title.is_none() || self.description.is_none() {
|
||||
setters.push(quote!{
|
||||
const title_and_description: (&str, &str) = schemars::_private::get_title_and_description(#doc);
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(title) = &self.title {
|
||||
setters.push(quote! {
|
||||
schemars::_private::insert_metadata_property(&mut schema, "title", #title);
|
||||
schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "title", #title);
|
||||
});
|
||||
} else if self.doc.is_some() {
|
||||
setters.push(quote! {
|
||||
schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "title", title_and_description.0);
|
||||
});
|
||||
}
|
||||
if let Some(description) = &self.description {
|
||||
setters.push(quote! {
|
||||
schemars::_private::insert_metadata_property(&mut schema, "description", #description);
|
||||
schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "description", #description);
|
||||
});
|
||||
} else if self.doc.is_some() {
|
||||
setters.push(quote! {
|
||||
schemars::_private::insert_metadata_property_if_nonempty(&mut schema, "description", title_and_description.1);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue