Allow arbitrary expressions in doc/title/description attributes (#327)

This commit is contained in:
Graham Esau 2024-08-24 14:35:30 +01:00 committed by GitHub
parent 5547e77bcd
commit df06fc5f66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 206 additions and 159 deletions

View file

@ -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

View file

@ -9,7 +9,7 @@
[![CI Build](https://img.shields.io/github/actions/workflow/status/GREsau/schemars/ci.yml?branch=master&logo=GitHub)](https://github.com/GREsau/schemars/actions)
[![Crates.io](https://img.shields.io/crates/v/schemars)](https://crates.io/crates/schemars)
[![Docs](https://img.shields.io/docsrs/schemars/1.0.0--latest?label=docs)](https://docs.rs/schemars/1.0.0--latest)
[![MSRV 1.60+](https://img.shields.io/crates/msrv/schemars)](https://blog.rust-lang.org/2022/04/07/Rust-1.60.0.html)
[![MSRV 1.65+](https://img.shields.io/crates/msrv/schemars)](https://blog.rust-lang.org/2022/11/03/Rust-1.65.0.html)
Generate JSON Schema documents from Rust code

View file

@ -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" }

View file

@ -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,

View 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
}

View file

@ -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);

View file

@ -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);

View file

@ -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]

View file

@ -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"
]
}

View file

@ -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": {

View file

@ -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"
}
}
}

View file

@ -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

View file

@ -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),
})

View file

@ -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,
}

View file

@ -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)))
}
}

View file

@ -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,

View file

@ -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);
});
}